image and animation progress
This commit is contained in:
61
README.md
61
README.md
@@ -11,7 +11,6 @@ This library is under active development. While many features are functional, so
|
||||
### Coming Soon
|
||||
The following features are currently being actively developed:
|
||||
- **Animations**(in progress): Simple to use animations for UI transitions and effects
|
||||
- **Generic Image Support**(in progress): Enhanced image rendering capabilities and utilities
|
||||
- **Multi-touch Support**(on hold): Support for multi-touch events with additional parameters
|
||||
|
||||
|
||||
@@ -24,6 +23,7 @@ The following features are currently being actively developed:
|
||||
- **Theme System**: 9-patch (NinePatch) theming with state support (normal, hover, pressed, disabled)
|
||||
- **Android 9-Patch Auto-Parsing**: Automatic parsing of *.9.png files with multi-region support
|
||||
- **Animations**: Built-in animation support for transitions and effects
|
||||
- **Image Support**: CSS-like object-fit, object-position, tiling/repeat modes, tinting, and opacity control
|
||||
- **Responsive Design**: Automatic resizing with viewport units (vw, vh, %)
|
||||
- **Color Handling**: Utility classes for managing colors in various formats
|
||||
- **Text Rendering**: Flexible text display with alignment and auto-scaling
|
||||
@@ -232,6 +232,15 @@ Common properties for all elements:
|
||||
textAlign = "start", -- "start", "center", "end"
|
||||
textSize = "md", -- Number or preset ("xs", "sm", "md", "lg", "xl", etc.)
|
||||
|
||||
-- Images
|
||||
imagePath = "path/to/image.png", -- Path to image file (auto-loads)
|
||||
image = imageObject, -- Or provide love.Image directly
|
||||
objectFit = "fill", -- "fill", "contain", "cover", "scale-down", "none"
|
||||
objectPosition = "center center", -- Image positioning (keywords or percentages)
|
||||
imageRepeat = "no-repeat", -- "no-repeat", "repeat", "repeat-x", "repeat-y", "space", "round"
|
||||
imageTint = Color.new(1, 1, 1, 1), -- Color tint overlay (default: white/no tint)
|
||||
imageOpacity = 1, -- Image opacity 0-1 (combines with element opacity)
|
||||
|
||||
-- Theming
|
||||
theme = "space", -- Theme name
|
||||
themeComponent = "button", -- Component type from theme
|
||||
@@ -557,6 +566,56 @@ local customAnim = Animation.new({
|
||||
customAnim:apply(element)
|
||||
```
|
||||
|
||||
### Images
|
||||
|
||||
Display images with CSS-like object-fit and positioning:
|
||||
|
||||
```lua
|
||||
local Gui = FlexLove.Gui
|
||||
|
||||
-- Basic image display
|
||||
local imageBox = Gui.new({
|
||||
width = 200,
|
||||
height = 200,
|
||||
imagePath = "assets/photo.jpg",
|
||||
objectFit = "cover", -- fill, contain, cover, scale-down, none
|
||||
objectPosition = "center center", -- positioning within bounds
|
||||
imageOpacity = 1.0,
|
||||
imageTint = Color.new(1, 1, 1, 1), -- optional color tint
|
||||
})
|
||||
```
|
||||
|
||||
**Object-fit modes:**
|
||||
- `fill` - Stretch to fill (may distort)
|
||||
- `contain` - Fit within bounds (preserves aspect ratio)
|
||||
- `cover` - Cover bounds (preserves aspect ratio, may crop)
|
||||
- `scale-down` - Use smaller of none or contain
|
||||
- `none` - Natural size (no scaling)
|
||||
|
||||
**Object-position examples:**
|
||||
- `"center center"` - Center both axes
|
||||
- `"top left"` - Top-left corner
|
||||
- `"bottom right"` - Bottom-right corner
|
||||
- `"50% 20%"` - Custom percentage positioning
|
||||
|
||||
**Image tiling:**
|
||||
```lua
|
||||
{
|
||||
imagePath = "pattern.png",
|
||||
imageRepeat = "repeat", -- repeat, repeat-x, repeat-y, no-repeat, space, round
|
||||
}
|
||||
```
|
||||
|
||||
**Image effects:**
|
||||
```lua
|
||||
{
|
||||
imageTint = Color.new(1, 0, 0, 1), -- Red tint overlay
|
||||
imageOpacity = 0.8, -- 80% opacity
|
||||
}
|
||||
```
|
||||
|
||||
See `examples/image_showcase.lua` for a comprehensive demonstration of all image features.
|
||||
|
||||
### Creating Colors
|
||||
|
||||
```lua
|
||||
|
||||
@@ -148,7 +148,7 @@ The release package includes **only** the files needed to use FlexLöve:
|
||||
- `FlexLove.lua` - Main library
|
||||
- `modules/` - All module files
|
||||
- `LICENSE` - License terms
|
||||
- `README.txt` - Installation instructions
|
||||
- `README.md` - Installation instructions
|
||||
|
||||
❌ **Not included:**
|
||||
- `docs/` - Documentation (hosted on GitHub Pages)
|
||||
|
||||
279
examples/image_showcase.lua
Normal file
279
examples/image_showcase.lua
Normal file
@@ -0,0 +1,279 @@
|
||||
-- Image Showcase Example
|
||||
-- Demonstrates all image features in FlexLove
|
||||
|
||||
local FlexLove = require("libs.FlexLove")
|
||||
local Color = FlexLove.Color
|
||||
-- I use this to avoid lsp warnings
|
||||
local lv = love
|
||||
|
||||
-- Set to immediate mode for this example
|
||||
FlexLove.setMode("immediate")
|
||||
|
||||
function lv.load()
|
||||
-- Set window size
|
||||
lv.window.setMode(1200, 800, { resizable = true })
|
||||
lv.window.setTitle("FlexLove Image Showcase")
|
||||
end
|
||||
|
||||
function lv.draw()
|
||||
local container = FlexLove.new({
|
||||
width = "100vw",
|
||||
height = "100vh",
|
||||
flexDirection = "vertical",
|
||||
padding = { top = 20, right = 20, bottom = 20, left = 20 },
|
||||
gap = 20,
|
||||
backgroundColor = Color.new(0.95, 0.95, 0.95, 1),
|
||||
})
|
||||
|
||||
-- Title
|
||||
local title = FlexLove.new({
|
||||
parent = container,
|
||||
text = "FlexLove Image Showcase",
|
||||
textSize = "xxl",
|
||||
textColor = Color.new(0.2, 0.2, 0.2, 1),
|
||||
textAlign = "center",
|
||||
padding = { top = 0, right = 0, bottom = 20, left = 0 },
|
||||
})
|
||||
|
||||
-- Section 1: Object-Fit Modes
|
||||
local fitSection = FlexLove.new({
|
||||
parent = container,
|
||||
flexDirection = "vertical",
|
||||
gap = 10,
|
||||
})
|
||||
|
||||
local fitTitle = FlexLove.new({
|
||||
parent = fitSection,
|
||||
text = "Object-Fit Modes",
|
||||
textSize = "lg",
|
||||
textColor = Color.new(0.3, 0.3, 0.3, 1),
|
||||
})
|
||||
|
||||
local fitRow = FlexLove.new({
|
||||
parent = fitSection,
|
||||
flexDirection = "horizontal",
|
||||
gap = 10,
|
||||
justifyContent = "space-around",
|
||||
})
|
||||
|
||||
local fitModes = { "fill", "contain", "cover", "scale-down", "none" }
|
||||
for _, mode in ipairs(fitModes) do
|
||||
local fitBox = FlexLove.new({
|
||||
parent = fitRow,
|
||||
width = 180,
|
||||
height = 120,
|
||||
flexDirection = "vertical",
|
||||
gap = 5,
|
||||
backgroundColor = Color.new(1, 1, 1, 1),
|
||||
cornerRadius = 8,
|
||||
padding = { top = 10, right = 10, bottom = 10, left = 10 },
|
||||
})
|
||||
|
||||
local fitImage = FlexLove.new({
|
||||
parent = fitBox,
|
||||
width = 160,
|
||||
height = 80,
|
||||
backgroundColor = Color.new(0.9, 0.9, 0.9, 1),
|
||||
cornerRadius = 4,
|
||||
imagePath = "sample.jpg",
|
||||
objectFit = mode,
|
||||
})
|
||||
|
||||
local fitLabel = FlexLove.new({
|
||||
parent = fitBox,
|
||||
text = mode,
|
||||
textSize = "sm",
|
||||
textColor = Color.new(0.4, 0.4, 0.4, 1),
|
||||
textAlign = "center",
|
||||
})
|
||||
end
|
||||
|
||||
local posSection = FlexLove.new({
|
||||
parent = container,
|
||||
flexDirection = "vertical",
|
||||
gap = 10,
|
||||
})
|
||||
|
||||
local posTitle = FlexLove.new({
|
||||
parent = posSection,
|
||||
text = "Object-Position",
|
||||
textSize = "lg",
|
||||
textColor = Color.new(0.3, 0.3, 0.3, 1),
|
||||
})
|
||||
|
||||
local posRow = FlexLove.new({
|
||||
parent = posSection,
|
||||
flexDirection = "horizontal",
|
||||
gap = 10,
|
||||
justifyContent = "space-around",
|
||||
})
|
||||
|
||||
local positions = { "top left", "center center", "bottom right", "50% 20%", "left center" }
|
||||
for _, pos in ipairs(positions) do
|
||||
local posBox = FlexLove.new({
|
||||
parent = posRow,
|
||||
width = 180,
|
||||
height = 120,
|
||||
flexDirection = "vertical",
|
||||
gap = 5,
|
||||
backgroundColor = Color.new(1, 1, 1, 1),
|
||||
cornerRadius = 8,
|
||||
padding = { top = 10, right = 10, bottom = 10, left = 10 },
|
||||
})
|
||||
|
||||
local posImage = FlexLove.new({
|
||||
parent = posBox,
|
||||
width = 160,
|
||||
height = 80,
|
||||
backgroundColor = Color.new(0.9, 0.9, 0.9, 1),
|
||||
cornerRadius = 4,
|
||||
imagePath = "sample.jpg",
|
||||
objectFit = "none",
|
||||
objectPosition = pos,
|
||||
})
|
||||
|
||||
local posLabel = FlexLove.new({
|
||||
parent = posBox,
|
||||
text = pos,
|
||||
textSize = "xs",
|
||||
textColor = Color.new(0.4, 0.4, 0.4, 1),
|
||||
textAlign = "center",
|
||||
})
|
||||
end
|
||||
|
||||
-- Section 3: Image Tiling/Repeat
|
||||
local tileSection = FlexLove.new({
|
||||
parent = container,
|
||||
flexDirection = "vertical",
|
||||
gap = 10,
|
||||
})
|
||||
|
||||
local tileTitle = FlexLove.new({
|
||||
parent = tileSection,
|
||||
text = "Image Tiling (Repeat Modes)",
|
||||
textSize = "lg",
|
||||
textColor = Color.new(0.3, 0.3, 0.3, 1),
|
||||
})
|
||||
|
||||
local tileRow = FlexLove.new({
|
||||
parent = tileSection,
|
||||
flexDirection = "horizontal",
|
||||
gap = 10,
|
||||
justifyContent = "space-around",
|
||||
})
|
||||
|
||||
local repeatModes = { "no-repeat", "repeat", "repeat-x", "repeat-y" }
|
||||
for _, mode in ipairs(repeatModes) do
|
||||
local tileBox = FlexLove.new({
|
||||
parent = tileRow,
|
||||
width = 240,
|
||||
height = 120,
|
||||
flexDirection = "vertical",
|
||||
gap = 5,
|
||||
backgroundColor = Color.new(1, 1, 1, 1),
|
||||
cornerRadius = 8,
|
||||
padding = { top = 10, right = 10, bottom = 10, left = 10 },
|
||||
})
|
||||
|
||||
local tileImage = FlexLove.new({
|
||||
parent = tileBox,
|
||||
width = 220,
|
||||
height = 80,
|
||||
backgroundColor = Color.new(0.9, 0.9, 0.9, 1),
|
||||
cornerRadius = 4,
|
||||
-- imagePath = "assets/pattern.png", -- Uncomment if you have a pattern image
|
||||
imageRepeat = mode,
|
||||
})
|
||||
|
||||
local tileLabel = FlexLove.new({
|
||||
parent = tileBox,
|
||||
text = mode,
|
||||
textSize = "sm",
|
||||
textColor = Color.new(0.4, 0.4, 0.4, 1),
|
||||
textAlign = "center",
|
||||
})
|
||||
end
|
||||
|
||||
-- Section 4: Image Tinting and Opacity
|
||||
local tintSection = FlexLove.new({
|
||||
parent = container,
|
||||
flexDirection = "vertical",
|
||||
gap = 10,
|
||||
})
|
||||
|
||||
local tintTitle = FlexLove.new({
|
||||
parent = tintSection,
|
||||
text = "Image Tinting & Opacity",
|
||||
textSize = "lg",
|
||||
textColor = Color.new(0.3, 0.3, 0.3, 1),
|
||||
})
|
||||
|
||||
local tintRow = FlexLove.new({
|
||||
parent = tintSection,
|
||||
flexDirection = "horizontal",
|
||||
gap = 10,
|
||||
justifyContent = "space-around",
|
||||
})
|
||||
|
||||
local tints = {
|
||||
{ name = "No Tint", color = nil, opacity = 1 },
|
||||
{ name = "Red Tint", color = Color.new(1, 0.5, 0.5, 1), opacity = 1 },
|
||||
{ name = "Blue Tint", color = Color.new(0.5, 0.5, 1, 1), opacity = 1 },
|
||||
{ name = "50% Opacity", color = nil, opacity = 0.5 },
|
||||
{ name = "Green + 70%", color = Color.new(0.5, 1, 0.5, 1), opacity = 0.7 },
|
||||
}
|
||||
|
||||
for _, tint in ipairs(tints) do
|
||||
local tintBox = FlexLove.new({
|
||||
parent = tintRow,
|
||||
width = 180,
|
||||
height = 120,
|
||||
flexDirection = "vertical",
|
||||
gap = 5,
|
||||
backgroundColor = Color.new(1, 1, 1, 1),
|
||||
cornerRadius = 8,
|
||||
padding = { top = 10, right = 10, bottom = 10, left = 10 },
|
||||
})
|
||||
|
||||
local tintImage = FlexLove.new({
|
||||
parent = tintBox,
|
||||
width = 160,
|
||||
height = 80,
|
||||
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({
|
||||
parent = tintBox,
|
||||
text = tint.name,
|
||||
textSize = "xs",
|
||||
textColor = Color.new(0.4, 0.4, 0.4, 1),
|
||||
textAlign = "center",
|
||||
})
|
||||
end
|
||||
|
||||
-- Footer note
|
||||
local note = FlexLove.new({
|
||||
parent = container,
|
||||
text = "Note: Uncomment imagePath properties in code to see actual images",
|
||||
textSize = "xs",
|
||||
textColor = Color.new(0.5, 0.5, 0.5, 1),
|
||||
textAlign = "center",
|
||||
padding = { top = 10, right = 0, bottom = 0, left = 0 },
|
||||
})
|
||||
end
|
||||
|
||||
function lv.mousepressed(x, y, button)
|
||||
FlexLove.mousepressed(x, y, button)
|
||||
end
|
||||
|
||||
function lv.mousereleased(x, y, button)
|
||||
FlexLove.mousereleased(x, y, button)
|
||||
end
|
||||
|
||||
function lv.mousemoved(x, y, dx, dy)
|
||||
FlexLove.mousemoved(x, y, dx, dy)
|
||||
end
|
||||
@@ -12,7 +12,7 @@ function InputExample:new()
|
||||
keyPressed = "",
|
||||
touchPosition = { x = 0, y = 0 },
|
||||
isMouseOver = false,
|
||||
hoverCount = 0
|
||||
hoverCount = 0,
|
||||
}
|
||||
setmetatable(obj, { __index = self })
|
||||
return obj
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
--- Performance Monitoring Example
|
||||
--- Demonstrates how to use the Performance module
|
||||
|
||||
package.path = package.path .. ";./?.lua;./modules/?.lua"
|
||||
|
||||
-- Load love stub and Performance module
|
||||
require("testing.loveStub")
|
||||
local Performance = require("modules.Performance")
|
||||
|
||||
print("=== Performance Module Example ===\n")
|
||||
|
||||
-- 1. Initialize and enable performance monitoring
|
||||
print("1. Initializing Performance monitoring...")
|
||||
Performance.init({
|
||||
enabled = true,
|
||||
logToConsole = true,
|
||||
logWarnings = true,
|
||||
})
|
||||
print(" Enabled: " .. tostring(Performance.isEnabled()))
|
||||
print()
|
||||
|
||||
-- 2. Test basic timer functionality
|
||||
print("2. Testing timers...")
|
||||
Performance.startTimer("test_operation")
|
||||
-- Simulate some work
|
||||
local sum = 0
|
||||
for i = 1, 1000000 do
|
||||
sum = sum + i
|
||||
end
|
||||
local elapsed = Performance.stopTimer("test_operation")
|
||||
print(string.format(" Test operation completed in %.3fms", elapsed))
|
||||
print()
|
||||
|
||||
-- 3. Test measure wrapper
|
||||
print("3. Testing measure wrapper...")
|
||||
local expensiveFunction = function(n)
|
||||
local result = 0
|
||||
for i = 1, n do
|
||||
result = result + math.sqrt(i)
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
local measuredFunction = Performance.measure("expensive_calculation", expensiveFunction)
|
||||
local result = measuredFunction(100000)
|
||||
print(string.format(" Expensive calculation result: %.2f", result))
|
||||
print()
|
||||
|
||||
-- 4. Simulate frame timing
|
||||
print("4. Simulating frame timing...")
|
||||
for _ = 1, 10 do
|
||||
Performance.startFrame()
|
||||
|
||||
-- Simulate frame work
|
||||
Performance.startTimer("frame_layout")
|
||||
local layoutSum = 0
|
||||
for i = 1, 50000 do
|
||||
layoutSum = layoutSum + i
|
||||
end
|
||||
Performance.stopTimer("frame_layout")
|
||||
|
||||
Performance.startTimer("frame_render")
|
||||
local renderSum = 0
|
||||
for i = 1, 30000 do
|
||||
renderSum = renderSum + i
|
||||
end
|
||||
Performance.stopTimer("frame_render")
|
||||
|
||||
Performance.endFrame()
|
||||
end
|
||||
print(string.format(" Simulated %d frames", 10))
|
||||
print()
|
||||
|
||||
-- 5. Get and display metrics
|
||||
print("5. Performance Metrics:")
|
||||
local metrics = Performance.getMetrics()
|
||||
print(string.format(" FPS: %d", metrics.frame.fps))
|
||||
print(string.format(" Average Frame Time: %.3fms", metrics.frame.averageFrameTime))
|
||||
print(string.format(" Min/Max Frame Time: %.3f/%.3fms", metrics.frame.minFrameTime, metrics.frame.maxFrameTime))
|
||||
print(string.format(" Memory: %.2f MB (peak: %.2f MB)", metrics.memory.currentMb, metrics.memory.peakMb))
|
||||
print()
|
||||
|
||||
print("6. Top Timings:")
|
||||
for name, data in pairs(metrics.timings) do
|
||||
print(string.format(" %s:", name))
|
||||
print(string.format(" Average: %.3fms", data.average))
|
||||
print(string.format(" Min/Max: %.3f/%.3fms", data.min, data.max))
|
||||
print(string.format(" Count: %d", data.count))
|
||||
end
|
||||
print()
|
||||
|
||||
-- 7. Export metrics
|
||||
print("7. Exporting metrics...")
|
||||
local json = Performance.exportJSON()
|
||||
print(" JSON Export:")
|
||||
print(json)
|
||||
print()
|
||||
|
||||
local csv = Performance.exportCSV()
|
||||
print(" CSV Export:")
|
||||
print(csv)
|
||||
print()
|
||||
|
||||
-- 8. Test warnings
|
||||
print("8. Recent Warnings:")
|
||||
local warnings = Performance.getWarnings(5)
|
||||
if #warnings > 0 then
|
||||
for _, warning in ipairs(warnings) do
|
||||
print(string.format(" [%s] %s: %.3fms", warning.level, warning.name, warning.value))
|
||||
end
|
||||
else
|
||||
print(" No warnings")
|
||||
end
|
||||
print()
|
||||
|
||||
-- 9. Reset and verify
|
||||
print("9. Testing reset...")
|
||||
Performance.reset()
|
||||
local newMetrics = Performance.getMetrics()
|
||||
print(string.format(" Frame count after reset: %d", newMetrics.frame.frameCount))
|
||||
print(string.format(" Timings count after reset: %d", #newMetrics.timings))
|
||||
print()
|
||||
|
||||
print("=== Performance Module Example Complete ===")
|
||||
BIN
examples/sample.jpg
Normal file
BIN
examples/sample.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
@@ -1,8 +1,6 @@
|
||||
--- Example demonstrating how to create sliders using FlexLove
|
||||
-- This example shows the implementation pattern used in SettingsMenu.lua
|
||||
local FlexLove = require("libs.FlexLove")
|
||||
local Theme = FlexLove.Theme
|
||||
local Color = FlexLove.Color
|
||||
local helpers = require("utils.helperFunctions")
|
||||
local round = helpers.round
|
||||
|
||||
@@ -157,3 +155,4 @@ function SliderExample:render_example()
|
||||
end
|
||||
|
||||
return SliderExample.init()
|
||||
|
||||
|
||||
@@ -244,4 +244,3 @@ function StatefulUIExample:render()
|
||||
end
|
||||
|
||||
return StatefulUIExample
|
||||
|
||||
|
||||
@@ -141,4 +141,3 @@ function ThemeExample:render()
|
||||
end
|
||||
|
||||
return ThemeExample
|
||||
|
||||
|
||||
@@ -46,22 +46,27 @@ local Easing = {
|
||||
}
|
||||
---@class AnimationProps
|
||||
---@field duration number Duration in seconds
|
||||
---@field start {width?:number, height?:number, opacity?:number} Starting values
|
||||
---@field final {width?:number, height?:number, opacity?:number} Final values
|
||||
---@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 easing string? Easing function name (default: "linear")
|
||||
---@field transform table? Additional transform properties
|
||||
---@field transition table? Transition properties
|
||||
---@field onStart function? Called when animation starts: (animation, element)
|
||||
---@field onUpdate function? Called each frame: (animation, element, progress)
|
||||
---@field onComplete function? Called when animation completes: (animation, element)
|
||||
---@field onCancel function? Called when animation is cancelled: (animation, element)
|
||||
|
||||
---@class Animation
|
||||
---@field duration number Duration in seconds
|
||||
---@field start {width?:number, height?:number, opacity?:number} Starting values
|
||||
---@field final {width?:number, height?:number, opacity?:number} Final values
|
||||
---@field start table Starting values
|
||||
---@field final table Final values
|
||||
---@field elapsed number Elapsed time in seconds
|
||||
---@field easing EasingFunction Easing function
|
||||
---@field transform table? Additional transform properties
|
||||
---@field transition table? Transition properties
|
||||
---@field _cachedResult table Cached interpolation result
|
||||
---@field _resultDirty boolean Whether cached result needs recalculation
|
||||
---@field _Color table? Reference to Color module (for lerp)
|
||||
local Animation = {}
|
||||
Animation.__index = Animation
|
||||
|
||||
@@ -94,6 +99,19 @@ function Animation.new(props)
|
||||
self.transition = props.transition
|
||||
self.elapsed = 0
|
||||
|
||||
-- Lifecycle callbacks
|
||||
self.onStart = props.onStart
|
||||
self.onUpdate = props.onUpdate
|
||||
self.onComplete = props.onComplete
|
||||
self.onCancel = props.onCancel
|
||||
self._hasStarted = false
|
||||
|
||||
-- Control state
|
||||
self._paused = false
|
||||
self._reversed = false
|
||||
self._speed = 1.0
|
||||
self._state = "pending" -- "pending", "playing", "paused", "completed", "cancelled"
|
||||
|
||||
-- Validate and set easing function
|
||||
local easingName = props.easing or "linear"
|
||||
if type(easingName) == "string" then
|
||||
@@ -113,24 +131,140 @@ end
|
||||
|
||||
---Update the animation with delta time
|
||||
---@param dt number Delta time in seconds
|
||||
---@param element table? Optional element reference for callbacks
|
||||
---@return boolean completed True if animation is complete
|
||||
function Animation:update(dt)
|
||||
function Animation:update(dt, element)
|
||||
-- Sanitize dt
|
||||
if type(dt) ~= "number" or dt < 0 or dt ~= dt or dt == math.huge then
|
||||
dt = 0
|
||||
end
|
||||
|
||||
self.elapsed = self.elapsed + dt
|
||||
self._resultDirty = true
|
||||
if self.elapsed >= self.duration then
|
||||
return true
|
||||
else
|
||||
-- Don't update if paused
|
||||
if self._paused then
|
||||
return false
|
||||
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, element)
|
||||
if not success then
|
||||
-- Log error but don't crash
|
||||
print(string.format("[Animation] onStart error: %s", tostring(err)))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Apply speed multiplier
|
||||
dt = dt * self._speed
|
||||
|
||||
-- Update elapsed time (reversed if needed)
|
||||
if self._reversed then
|
||||
self.elapsed = self.elapsed - dt
|
||||
if self.elapsed <= 0 then
|
||||
self.elapsed = 0
|
||||
self._state = "completed"
|
||||
self._resultDirty = true
|
||||
-- Call onComplete callback
|
||||
if self.onComplete and type(self.onComplete) == "function" then
|
||||
local success, err = pcall(self.onComplete, self, element)
|
||||
if not success then
|
||||
print(string.format("[Animation] onComplete error: %s", tostring(err)))
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
else
|
||||
self.elapsed = self.elapsed + dt
|
||||
if self.elapsed >= self.duration then
|
||||
self.elapsed = self.duration
|
||||
self._state = "completed"
|
||||
self._resultDirty = true
|
||||
-- Call onComplete callback
|
||||
if self.onComplete and type(self.onComplete) == "function" then
|
||||
local success, err = pcall(self.onComplete, self, element)
|
||||
if not success then
|
||||
print(string.format("[Animation] onComplete error: %s", tostring(err)))
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
self._resultDirty = true
|
||||
|
||||
-- Call onUpdate callback
|
||||
if self.onUpdate and type(self.onUpdate) == "function" then
|
||||
local progress = self.elapsed / self.duration
|
||||
local success, err = pcall(self.onUpdate, self, element, progress)
|
||||
if not success then
|
||||
print(string.format("[Animation] onUpdate error: %s", tostring(err)))
|
||||
end
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
--- Helper function to interpolate numeric values
|
||||
---@param startValue number Starting value
|
||||
---@param finalValue number Final value
|
||||
---@param easedT number Eased time (0-1)
|
||||
---@return number interpolated Interpolated value
|
||||
local function lerpNumber(startValue, finalValue, easedT)
|
||||
return startValue * (1 - easedT) + finalValue * easedT
|
||||
end
|
||||
|
||||
--- Helper function to interpolate Color values
|
||||
---@param startColor any Starting color (Color instance or parseable color)
|
||||
---@param finalColor any Final color (Color instance or parseable color)
|
||||
---@param easedT number Eased time (0-1)
|
||||
---@param ColorModule table Color module reference
|
||||
---@return any interpolated Interpolated Color instance
|
||||
local function lerpColor(startColor, finalColor, easedT, ColorModule)
|
||||
if not ColorModule then
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Parse colors if needed
|
||||
local colorA = ColorModule.parse(startColor)
|
||||
local colorB = ColorModule.parse(finalColor)
|
||||
|
||||
return ColorModule.lerp(colorA, colorB, easedT)
|
||||
end
|
||||
|
||||
--- Helper function to interpolate table values (padding, margin, cornerRadius)
|
||||
---@param startTable table Starting table
|
||||
---@param finalTable table Final table
|
||||
---@param easedT number Eased time (0-1)
|
||||
---@return table interpolated Interpolated table
|
||||
local function lerpTable(startTable, finalTable, easedT)
|
||||
local result = {}
|
||||
|
||||
-- Iterate through all keys in both tables
|
||||
local keys = {}
|
||||
for k in pairs(startTable) do keys[k] = true end
|
||||
for k in pairs(finalTable) do keys[k] = true end
|
||||
|
||||
for key in pairs(keys) do
|
||||
local startVal = startTable[key]
|
||||
local finalVal = finalTable[key]
|
||||
|
||||
if type(startVal) == "number" and type(finalVal) == "number" then
|
||||
result[key] = lerpNumber(startVal, finalVal, easedT)
|
||||
elseif startVal ~= nil then
|
||||
result[key] = startVal
|
||||
else
|
||||
result[key] = finalVal
|
||||
end
|
||||
end
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
---Interpolate animation values at current time
|
||||
---@return table result Interpolated values {width?, height?, opacity?, ...}
|
||||
---@return table result Interpolated values {width?, height?, opacity?, x?, y?, backgroundColor?, ...}
|
||||
function Animation:interpolate()
|
||||
-- Return cached result if not dirty (avoids recalculation)
|
||||
if not self._resultDirty then
|
||||
@@ -147,26 +281,67 @@ function Animation:interpolate()
|
||||
|
||||
local result = self._cachedResult -- Reuse existing table
|
||||
|
||||
result.width = nil
|
||||
result.height = nil
|
||||
result.opacity = nil
|
||||
|
||||
-- Interpolate width if both start and final are valid numbers
|
||||
if type(self.start.width) == "number" and type(self.final.width) == "number" then
|
||||
result.width = self.start.width * (1 - easedT) + self.final.width * easedT
|
||||
-- Clear previous results
|
||||
for k in pairs(result) do
|
||||
result[k] = nil
|
||||
end
|
||||
|
||||
-- Interpolate height if both start and final are valid numbers
|
||||
if type(self.start.height) == "number" and type(self.final.height) == "number" then
|
||||
result.height = self.start.height * (1 - easedT) + self.final.height * easedT
|
||||
-- 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"
|
||||
}
|
||||
|
||||
-- Interpolate numeric properties
|
||||
for _, prop in ipairs(numericProperties) do
|
||||
local startVal = self.start[prop]
|
||||
local finalVal = self.final[prop]
|
||||
|
||||
if type(startVal) == "number" and type(finalVal) == "number" then
|
||||
result[prop] = lerpNumber(startVal, finalVal, easedT)
|
||||
end
|
||||
end
|
||||
|
||||
-- Interpolate opacity if both start and final are valid numbers
|
||||
if type(self.start.opacity) == "number" and type(self.final.opacity) == "number" then
|
||||
result.opacity = self.start.opacity * (1 - easedT) + self.final.opacity * easedT
|
||||
-- Interpolate color properties (if Color module is available)
|
||||
if self._Color then
|
||||
for _, prop in ipairs(colorProperties) do
|
||||
local startVal = self.start[prop]
|
||||
local finalVal = self.final[prop]
|
||||
|
||||
if startVal ~= nil and finalVal ~= nil then
|
||||
result[prop] = lerpColor(startVal, finalVal, easedT, self._Color)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Copy transform properties
|
||||
-- Interpolate table properties
|
||||
for _, prop in ipairs(tableProperties) do
|
||||
local startVal = self.start[prop]
|
||||
local finalVal = self.final[prop]
|
||||
|
||||
if type(startVal) == "table" and type(finalVal) == "table" then
|
||||
result[prop] = lerpTable(startVal, finalVal, easedT)
|
||||
end
|
||||
end
|
||||
|
||||
-- Interpolate transform property (if Transform module is available)
|
||||
if self._Transform and self.start.transform and self.final.transform then
|
||||
result.transform = self._Transform.lerp(self.start.transform, self.final.transform, easedT)
|
||||
end
|
||||
|
||||
-- Copy transform properties (legacy support)
|
||||
if self.transform and type(self.transform) == "table" then
|
||||
for key, value in pairs(self.transform) do
|
||||
result[key] = value
|
||||
@@ -186,6 +361,109 @@ function Animation:apply(element)
|
||||
element.animation = self
|
||||
end
|
||||
|
||||
--- Set Color module reference for color interpolation
|
||||
---@param ColorModule table Color module
|
||||
function Animation:setColorModule(ColorModule)
|
||||
self._Color = ColorModule
|
||||
end
|
||||
|
||||
--- Set Transform module reference for transform interpolation
|
||||
---@param TransformModule table Transform module
|
||||
function Animation:setTransformModule(TransformModule)
|
||||
self._Transform = TransformModule
|
||||
end
|
||||
|
||||
---Pause the animation
|
||||
function Animation:pause()
|
||||
if self._state == "playing" or self._state == "pending" then
|
||||
self._paused = true
|
||||
self._state = "paused"
|
||||
end
|
||||
end
|
||||
|
||||
---Resume the animation
|
||||
function Animation:resume()
|
||||
if self._state == "paused" then
|
||||
self._paused = false
|
||||
self._state = "playing"
|
||||
end
|
||||
end
|
||||
|
||||
---Check if animation is paused
|
||||
---@return boolean paused
|
||||
function Animation:isPaused()
|
||||
return self._paused
|
||||
end
|
||||
|
||||
---Reverse the animation direction
|
||||
function Animation:reverse()
|
||||
self._reversed = not self._reversed
|
||||
end
|
||||
|
||||
---Check if animation is reversed
|
||||
---@return boolean reversed
|
||||
function Animation:isReversed()
|
||||
return self._reversed
|
||||
end
|
||||
|
||||
---Set animation playback speed
|
||||
---@param speed number Speed multiplier (1.0 = normal, 2.0 = double speed, 0.5 = half speed)
|
||||
function Animation:setSpeed(speed)
|
||||
if type(speed) == "number" and speed > 0 then
|
||||
self._speed = speed
|
||||
end
|
||||
end
|
||||
|
||||
---Get animation playback speed
|
||||
---@return number speed Current speed multiplier
|
||||
function Animation:getSpeed()
|
||||
return self._speed
|
||||
end
|
||||
|
||||
---Seek to a specific time in the animation
|
||||
---@param time number Time in seconds (clamped to 0-duration)
|
||||
function Animation:seek(time)
|
||||
if type(time) == "number" then
|
||||
self.elapsed = math.max(0, math.min(time, self.duration))
|
||||
self._resultDirty = true
|
||||
end
|
||||
end
|
||||
|
||||
---Get current animation state
|
||||
---@return string state Current state: "pending", "playing", "paused", "completed", "cancelled"
|
||||
function Animation:getState()
|
||||
return self._state
|
||||
end
|
||||
|
||||
---Cancel the animation
|
||||
---@param element table? Optional element reference for callback
|
||||
function Animation:cancel(element)
|
||||
if self._state ~= "cancelled" and self._state ~= "completed" then
|
||||
self._state = "cancelled"
|
||||
if self.onCancel and type(self.onCancel) == "function" then
|
||||
local success, err = pcall(self.onCancel, self, element)
|
||||
if not success then
|
||||
print(string.format("[Animation] onCancel error: %s", tostring(err)))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---Reset the animation to its initial state
|
||||
function Animation:reset()
|
||||
self.elapsed = 0
|
||||
self._hasStarted = false
|
||||
self._paused = false
|
||||
self._state = "pending"
|
||||
self._resultDirty = true
|
||||
end
|
||||
|
||||
---Get the current progress of the animation
|
||||
---@return number progress Progress from 0 to 1
|
||||
function Animation:getProgress()
|
||||
return math.min(self.elapsed / self.duration, 1)
|
||||
end
|
||||
|
||||
--- Create a simple fade animation
|
||||
---@param duration number Duration in seconds
|
||||
---@param fromOpacity number Starting opacity (0-1)
|
||||
|
||||
@@ -425,6 +425,35 @@ function Color.parse(value)
|
||||
return Color.sanitizeColor(value, Color.new(0, 0, 0, 1))
|
||||
end
|
||||
|
||||
--- Linear interpolation between two colors
|
||||
---@param colorA Color Starting color
|
||||
---@param colorB Color Ending color
|
||||
---@param t number Interpolation factor (0-1)
|
||||
---@return Color color Interpolated color
|
||||
function Color.lerp(colorA, colorB, t)
|
||||
-- Sanitize inputs
|
||||
if type(colorA) ~= "table" or getmetatable(colorA) ~= Color then
|
||||
colorA = Color.new(0, 0, 0, 1)
|
||||
end
|
||||
if type(colorB) ~= "table" or getmetatable(colorB) ~= Color then
|
||||
colorB = Color.new(0, 0, 0, 1)
|
||||
end
|
||||
if type(t) ~= "number" or t ~= t or t == math.huge or t == -math.huge then
|
||||
t = 0
|
||||
end
|
||||
|
||||
-- Clamp t to 0-1 range
|
||||
t = math.max(0, math.min(1, t))
|
||||
|
||||
-- Linear interpolation for each channel
|
||||
local r = colorA.r * (1 - t) + colorB.r * t
|
||||
local g = colorA.g * (1 - t) + colorB.g * t
|
||||
local b = colorA.b * (1 - t) + colorB.b * t
|
||||
local a = colorA.a * (1 - t) + colorB.a * t
|
||||
|
||||
return Color.new(r, g, b, a)
|
||||
end
|
||||
|
||||
-- Export ErrorHandler initializer
|
||||
Color.initializeErrorHandler = initializeErrorHandler
|
||||
|
||||
|
||||
@@ -109,6 +109,8 @@
|
||||
---@field objectFit "fill"|"contain"|"cover"|"scale-down"|"none"? -- Image fit mode (default: "fill")
|
||||
---@field objectPosition string? -- Image position like "center center", "top left", "50% 50%" (default: "center center")
|
||||
---@field imageOpacity number? -- Image opacity 0-1 (default: 1, combines with element opacity)
|
||||
---@field imageRepeat "no-repeat"|"repeat"|"repeat-x"|"repeat-y"|"space"|"round"? -- Image repeat/tiling mode (default: "no-repeat")
|
||||
---@field imageTint Color? -- Color to tint the image (default: nil/white, no tint)
|
||||
---@field _loadedImage love.Image? -- Internal: cached loaded image
|
||||
---@field hideScrollbars boolean|{vertical:boolean, horizontal:boolean}? -- Hide scrollbars (boolean for both, or table for individual control)
|
||||
---@field userdata table?
|
||||
@@ -176,6 +178,7 @@ function Element.new(props, deps)
|
||||
ImageCache = deps.ImageCache,
|
||||
Theme = deps.Theme,
|
||||
Blur = deps.Blur,
|
||||
Transform = deps.Transform,
|
||||
utils = deps.utils,
|
||||
}
|
||||
|
||||
@@ -389,6 +392,9 @@ function Element.new(props, deps)
|
||||
-- Set visibility property (default: "visible")
|
||||
self.visibility = props.visibility or "visible"
|
||||
|
||||
-- Set transform property (optional)
|
||||
self.transform = props.transform or nil
|
||||
|
||||
-- Handle cornerRadius (can be number or table)
|
||||
if props.cornerRadius then
|
||||
if type(props.cornerRadius) == "number" then
|
||||
@@ -451,6 +457,23 @@ function Element.new(props, deps)
|
||||
end
|
||||
self.imageOpacity = props.imageOpacity or 1
|
||||
|
||||
-- Validate and set imageRepeat
|
||||
if props.imageRepeat then
|
||||
local validImageRepeat = {
|
||||
["no-repeat"] = "no-repeat",
|
||||
["repeat"] = "repeat",
|
||||
["repeat-x"] = "repeat-x",
|
||||
["repeat-y"] = "repeat-y",
|
||||
space = "space",
|
||||
round = "round"
|
||||
}
|
||||
self._deps.utils.validateEnum(props.imageRepeat, validImageRepeat, "imageRepeat")
|
||||
end
|
||||
self.imageRepeat = props.imageRepeat or "no-repeat"
|
||||
|
||||
-- Set imageTint
|
||||
self.imageTint = props.imageTint
|
||||
|
||||
-- Auto-load image if imagePath is provided
|
||||
if self.imagePath and not self.image then
|
||||
local loadedImage, err = self._deps.ImageCache.load(self.imagePath)
|
||||
@@ -483,6 +506,8 @@ function Element.new(props, deps)
|
||||
objectFit = self.objectFit,
|
||||
objectPosition = self.objectPosition,
|
||||
imageOpacity = self.imageOpacity,
|
||||
imageRepeat = self.imageRepeat,
|
||||
imageTint = self.imageTint,
|
||||
contentBlur = self.contentBlur,
|
||||
backdropBlur = self.backdropBlur,
|
||||
}, rendererDeps)
|
||||
@@ -2026,17 +2051,75 @@ function Element:update(dt)
|
||||
|
||||
-- Update animation if exists
|
||||
if self.animation then
|
||||
local finished = self.animation:update(dt)
|
||||
-- Ensure animation has Color module reference for color interpolation
|
||||
if not self.animation._Color and self._deps.Color then
|
||||
self.animation:setColorModule(self._deps.Color)
|
||||
end
|
||||
|
||||
-- Ensure animation has Transform module reference for transform interpolation
|
||||
if not self.animation._Transform and self._deps.Transform then
|
||||
self.animation:setTransformModule(self._deps.Transform)
|
||||
end
|
||||
|
||||
local finished = self.animation:update(dt, self)
|
||||
if finished then
|
||||
-- Animation:update() already called onComplete callback
|
||||
self.animation = nil -- remove finished animation
|
||||
else
|
||||
-- Apply animation interpolation during update
|
||||
local anim = self.animation:interpolate()
|
||||
|
||||
-- Apply numeric properties
|
||||
self.width = anim.width or self.width
|
||||
self.height = anim.height or self.height
|
||||
self.opacity = anim.opacity or self.opacity
|
||||
-- Update background color with interpolated opacity
|
||||
if anim.opacity then
|
||||
self.x = anim.x or self.x
|
||||
self.y = anim.y or self.y
|
||||
self.gap = anim.gap or self.gap
|
||||
self.imageOpacity = anim.imageOpacity or self.imageOpacity
|
||||
self.scrollbarWidth = anim.scrollbarWidth or self.scrollbarWidth
|
||||
self.borderWidth = anim.borderWidth or self.borderWidth
|
||||
self.fontSize = anim.fontSize or self.fontSize
|
||||
self.lineHeight = anim.lineHeight or self.lineHeight
|
||||
|
||||
-- Apply color properties
|
||||
if anim.backgroundColor then
|
||||
self.backgroundColor = anim.backgroundColor
|
||||
end
|
||||
if anim.borderColor then
|
||||
self.borderColor = anim.borderColor
|
||||
end
|
||||
if anim.textColor then
|
||||
self.textColor = anim.textColor
|
||||
end
|
||||
if anim.scrollbarColor then
|
||||
self.scrollbarColor = anim.scrollbarColor
|
||||
end
|
||||
if anim.scrollbarBackgroundColor then
|
||||
self.scrollbarBackgroundColor = anim.scrollbarBackgroundColor
|
||||
end
|
||||
if anim.imageTint then
|
||||
self.imageTint = anim.imageTint
|
||||
end
|
||||
|
||||
-- Apply table properties
|
||||
if anim.padding then
|
||||
self.padding = anim.padding
|
||||
end
|
||||
if anim.margin then
|
||||
self.margin = anim.margin
|
||||
end
|
||||
if anim.cornerRadius then
|
||||
self.cornerRadius = anim.cornerRadius
|
||||
end
|
||||
|
||||
-- Apply transform property
|
||||
if anim.transform then
|
||||
self.transform = anim.transform
|
||||
end
|
||||
|
||||
-- Backward compatibility: Update background color with interpolated opacity
|
||||
if anim.opacity and not anim.backgroundColor then
|
||||
self.backgroundColor.a = anim.opacity
|
||||
end
|
||||
end
|
||||
@@ -2741,4 +2824,85 @@ function Element:_trackActiveAnimations()
|
||||
end
|
||||
end
|
||||
|
||||
--- Set image tint color
|
||||
---@param color Color Color to tint the image
|
||||
function Element:setImageTint(color)
|
||||
self.imageTint = color
|
||||
if self._renderer then
|
||||
self._renderer.imageTint = color
|
||||
end
|
||||
end
|
||||
|
||||
--- Set image opacity
|
||||
---@param opacity number Opacity 0-1
|
||||
function Element:setImageOpacity(opacity)
|
||||
if opacity ~= nil then
|
||||
self._deps.utils.validateRange(opacity, 0, 1, "imageOpacity")
|
||||
end
|
||||
self.imageOpacity = opacity
|
||||
if self._renderer then
|
||||
self._renderer.imageOpacity = opacity
|
||||
end
|
||||
end
|
||||
|
||||
--- Set image repeat mode
|
||||
---@param repeatMode string Repeat mode: "no-repeat", "repeat", "repeat-x", "repeat-y", "space", "round"
|
||||
function Element:setImageRepeat(repeatMode)
|
||||
local validImageRepeat = {
|
||||
["no-repeat"] = "no-repeat",
|
||||
["repeat"] = "repeat",
|
||||
["repeat-x"] = "repeat-x",
|
||||
["repeat-y"] = "repeat-y",
|
||||
space = "space",
|
||||
round = "round"
|
||||
}
|
||||
self._deps.utils.validateEnum(repeatMode, validImageRepeat, "imageRepeat")
|
||||
self.imageRepeat = repeatMode
|
||||
if self._renderer then
|
||||
self._renderer.imageRepeat = repeatMode
|
||||
end
|
||||
end
|
||||
|
||||
--- Rotate element by angle
|
||||
---@param angle number Angle in radians
|
||||
function Element:rotate(angle)
|
||||
if not self.transform then
|
||||
self.transform = self._deps.Transform.new({})
|
||||
end
|
||||
self.transform.rotate = angle
|
||||
end
|
||||
|
||||
--- Scale element
|
||||
---@param scaleX number X-axis scale
|
||||
---@param scaleY number? Y-axis scale (defaults to scaleX)
|
||||
function Element:scale(scaleX, scaleY)
|
||||
if not self.transform then
|
||||
self.transform = self._deps.Transform.new({})
|
||||
end
|
||||
self.transform.scaleX = scaleX
|
||||
self.transform.scaleY = scaleY or scaleX
|
||||
end
|
||||
|
||||
--- Translate element
|
||||
---@param x number X translation
|
||||
---@param y number Y translation
|
||||
function Element:translate(x, y)
|
||||
if not self.transform then
|
||||
self.transform = self._deps.Transform.new({})
|
||||
end
|
||||
self.transform.translateX = x
|
||||
self.transform.translateY = y
|
||||
end
|
||||
|
||||
--- Set transform origin
|
||||
---@param originX number X origin (0-1, where 0.5 is center)
|
||||
---@param originY number Y origin (0-1, where 0.5 is center)
|
||||
function Element:setTransformOrigin(originX, originY)
|
||||
if not self.transform then
|
||||
self.transform = self._deps.Transform.new({})
|
||||
end
|
||||
self.transform.originX = originX
|
||||
self.transform.originY = originY
|
||||
end
|
||||
|
||||
return Element
|
||||
|
||||
@@ -197,7 +197,8 @@ end
|
||||
---@param fitMode string? -- Object-fit mode (default: "fill")
|
||||
---@param objectPosition string? -- Object-position (default: "center center")
|
||||
---@param opacity number? -- Opacity 0-1 (default: 1)
|
||||
function ImageRenderer.draw(image, x, y, width, height, fitMode, objectPosition, opacity)
|
||||
---@param tintColor Color? -- Color to tint the image (default: white/no tint)
|
||||
function ImageRenderer.draw(image, x, y, width, height, fitMode, objectPosition, opacity, tintColor)
|
||||
if not image then
|
||||
return -- Nothing to draw
|
||||
end
|
||||
@@ -212,8 +213,12 @@ function ImageRenderer.draw(image, x, y, width, height, fitMode, objectPosition,
|
||||
-- Save current color
|
||||
local r, g, b, a = love.graphics.getColor()
|
||||
|
||||
-- Apply opacity
|
||||
-- Apply opacity and tint
|
||||
if tintColor then
|
||||
love.graphics.setColor(tintColor.r, tintColor.g, tintColor.b, tintColor.a * opacity)
|
||||
else
|
||||
love.graphics.setColor(1, 1, 1, opacity)
|
||||
end
|
||||
|
||||
-- Draw image
|
||||
if params.sx ~= 0 or params.sy ~= 0 or params.sw ~= imgWidth or params.sh ~= imgHeight then
|
||||
@@ -229,4 +234,139 @@ function ImageRenderer.draw(image, x, y, width, height, fitMode, objectPosition,
|
||||
love.graphics.setColor(r, g, b, a)
|
||||
end
|
||||
|
||||
--- Draw an image with tiling/repeat mode
|
||||
---@param image love.Image -- Image to draw
|
||||
---@param x number -- X position of bounds
|
||||
---@param y number -- Y position of bounds
|
||||
---@param width number -- Width of bounds
|
||||
---@param height number -- Height of bounds
|
||||
---@param repeatMode string? -- Repeat mode: "repeat", "repeat-x", "repeat-y", "no-repeat", "space", "round" (default: "no-repeat")
|
||||
---@param opacity number? -- Opacity 0-1 (default: 1)
|
||||
---@param tintColor Color? -- Color to tint the image (default: white/no tint)
|
||||
function ImageRenderer.drawTiled(image, x, y, width, height, repeatMode, opacity, tintColor)
|
||||
if not image then
|
||||
return -- Nothing to draw
|
||||
end
|
||||
|
||||
opacity = opacity or 1
|
||||
repeatMode = repeatMode or "no-repeat"
|
||||
|
||||
local imgWidth, imgHeight = image:getDimensions()
|
||||
|
||||
-- Save current color
|
||||
local r, g, b, a = love.graphics.getColor()
|
||||
|
||||
-- Apply opacity and tint
|
||||
if tintColor then
|
||||
love.graphics.setColor(tintColor.r, tintColor.g, tintColor.b, tintColor.a * opacity)
|
||||
else
|
||||
love.graphics.setColor(1, 1, 1, opacity)
|
||||
end
|
||||
|
||||
if repeatMode == "no-repeat" then
|
||||
-- Just draw once, no tiling
|
||||
love.graphics.draw(image, x, y)
|
||||
elseif repeatMode == "repeat" then
|
||||
-- Tile in both directions
|
||||
local tilesX = math.ceil(width / imgWidth)
|
||||
local tilesY = math.ceil(height / imgHeight)
|
||||
|
||||
for tileY = 0, tilesY - 1 do
|
||||
for tileX = 0, tilesX - 1 do
|
||||
local drawX = x + (tileX * imgWidth)
|
||||
local drawY = y + (tileY * imgHeight)
|
||||
|
||||
-- Calculate how much of the tile to draw (for partial tiles at edges)
|
||||
local drawWidth = math.min(imgWidth, width - (tileX * imgWidth))
|
||||
local drawHeight = math.min(imgHeight, height - (tileY * imgHeight))
|
||||
|
||||
if drawWidth < imgWidth or drawHeight < imgHeight then
|
||||
-- Use quad for partial tile
|
||||
local quad = love.graphics.newQuad(0, 0, drawWidth, drawHeight, imgWidth, imgHeight)
|
||||
love.graphics.draw(image, quad, drawX, drawY)
|
||||
else
|
||||
-- Draw full tile
|
||||
love.graphics.draw(image, drawX, drawY)
|
||||
end
|
||||
end
|
||||
end
|
||||
elseif repeatMode == "repeat-x" then
|
||||
-- Tile horizontally only
|
||||
local tilesX = math.ceil(width / imgWidth)
|
||||
|
||||
for tileX = 0, tilesX - 1 do
|
||||
local drawX = x + (tileX * imgWidth)
|
||||
local drawWidth = math.min(imgWidth, width - (tileX * imgWidth))
|
||||
|
||||
if drawWidth < imgWidth then
|
||||
-- Use quad for partial tile
|
||||
local quad = love.graphics.newQuad(0, 0, drawWidth, imgHeight, imgWidth, imgHeight)
|
||||
love.graphics.draw(image, quad, drawX, y)
|
||||
else
|
||||
-- Draw full tile
|
||||
love.graphics.draw(image, drawX, y)
|
||||
end
|
||||
end
|
||||
elseif repeatMode == "repeat-y" then
|
||||
-- Tile vertically only
|
||||
local tilesY = math.ceil(height / imgHeight)
|
||||
|
||||
for tileY = 0, tilesY - 1 do
|
||||
local drawY = y + (tileY * imgHeight)
|
||||
local drawHeight = math.min(imgHeight, height - (tileY * imgHeight))
|
||||
|
||||
if drawHeight < imgHeight then
|
||||
-- Use quad for partial tile
|
||||
local quad = love.graphics.newQuad(0, 0, imgWidth, drawHeight, imgWidth, imgHeight)
|
||||
love.graphics.draw(image, quad, x, drawY)
|
||||
else
|
||||
-- Draw full tile
|
||||
love.graphics.draw(image, x, drawY)
|
||||
end
|
||||
end
|
||||
elseif repeatMode == "space" then
|
||||
-- Distribute tiles with even spacing
|
||||
local tilesX = math.floor(width / imgWidth)
|
||||
local tilesY = math.floor(height / imgHeight)
|
||||
|
||||
if tilesX < 1 then tilesX = 1 end
|
||||
if tilesY < 1 then tilesY = 1 end
|
||||
|
||||
local spaceX = tilesX > 1 and (width - (tilesX * imgWidth)) / (tilesX - 1) or 0
|
||||
local spaceY = tilesY > 1 and (height - (tilesY * imgHeight)) / (tilesY - 1) or 0
|
||||
|
||||
for tileY = 0, tilesY - 1 do
|
||||
for tileX = 0, tilesX - 1 do
|
||||
local drawX = x + (tileX * (imgWidth + spaceX))
|
||||
local drawY = y + (tileY * (imgHeight + spaceY))
|
||||
love.graphics.draw(image, drawX, drawY)
|
||||
end
|
||||
end
|
||||
elseif repeatMode == "round" then
|
||||
-- Scale tiles to fit bounds exactly
|
||||
local tilesX = math.max(1, math.round(width / imgWidth))
|
||||
local tilesY = math.max(1, math.round(height / imgHeight))
|
||||
|
||||
local scaleX = width / (tilesX * imgWidth)
|
||||
local scaleY = height / (tilesY * imgHeight)
|
||||
|
||||
for tileY = 0, tilesY - 1 do
|
||||
for tileX = 0, tilesX - 1 do
|
||||
local drawX = x + (tileX * imgWidth * scaleX)
|
||||
local drawY = y + (tileY * imgHeight * scaleY)
|
||||
love.graphics.draw(image, drawX, drawY, 0, scaleX, scaleY)
|
||||
end
|
||||
end
|
||||
else
|
||||
ErrorHandler.warn("ImageRenderer", "VAL_007", string.format("Invalid repeat mode: '%s'. Using 'no-repeat'", tostring(repeatMode)), {
|
||||
repeatMode = repeatMode,
|
||||
fallback = "no-repeat"
|
||||
})
|
||||
love.graphics.draw(image, x, y)
|
||||
end
|
||||
|
||||
-- Restore color
|
||||
love.graphics.setColor(r, g, b, a)
|
||||
end
|
||||
|
||||
return ImageRenderer
|
||||
|
||||
@@ -35,7 +35,7 @@ local ErrorHandler
|
||||
|
||||
--- Create a new Renderer instance
|
||||
---@param config table Configuration table with rendering properties
|
||||
---@param deps table Dependencies {Color, RoundedRect, NinePatch, ImageRenderer, ImageCache, Theme, Blur, utils}
|
||||
---@param deps table Dependencies {Color, RoundedRect, NinePatch, ImageRenderer, ImageCache, Theme, Blur, Transform, utils}
|
||||
function Renderer.new(config, deps)
|
||||
local Color = deps.Color
|
||||
local ImageCache = deps.ImageCache
|
||||
@@ -50,6 +50,7 @@ function Renderer.new(config, deps)
|
||||
self._ImageCache = ImageCache
|
||||
self._Theme = deps.Theme
|
||||
self._Blur = deps.Blur
|
||||
self._Transform = deps.Transform
|
||||
self._utils = deps.utils
|
||||
self._FONT_CACHE = deps.utils.FONT_CACHE
|
||||
self._TextAlign = deps.utils.enums.TextAlign
|
||||
@@ -90,6 +91,8 @@ function Renderer.new(config, deps)
|
||||
self.objectFit = config.objectFit or "fill"
|
||||
self.objectPosition = config.objectPosition or "center center"
|
||||
self.imageOpacity = config.imageOpacity or 1
|
||||
self.imageRepeat = config.imageRepeat or "no-repeat"
|
||||
self.imageTint = config.imageTint
|
||||
|
||||
-- Blur effects
|
||||
self.contentBlur = config.contentBlur
|
||||
@@ -193,8 +196,14 @@ function Renderer:_drawImage(x, y, paddingLeft, paddingTop, contentWidth, conten
|
||||
love.graphics.setStencilTest("greater", 0)
|
||||
end
|
||||
|
||||
-- Draw the image
|
||||
self._ImageRenderer.draw(self._loadedImage, imageX, imageY, imageWidth, imageHeight, self.objectFit, self.objectPosition, finalOpacity)
|
||||
-- Draw the image based on repeat mode
|
||||
if self.imageRepeat and self.imageRepeat ~= "no-repeat" then
|
||||
-- Use tiled rendering
|
||||
self._ImageRenderer.drawTiled(self._loadedImage, imageX, imageY, imageWidth, imageHeight, self.imageRepeat, finalOpacity, self.imageTint)
|
||||
else
|
||||
-- Use standard fit-based rendering
|
||||
self._ImageRenderer.draw(self._loadedImage, imageX, imageY, imageWidth, imageHeight, self.objectFit, self.objectPosition, finalOpacity, self.imageTint)
|
||||
end
|
||||
|
||||
-- Clear stencil if it was used
|
||||
if hasCornerRadius then
|
||||
@@ -347,6 +356,12 @@ function Renderer:draw(backdropCanvas)
|
||||
local borderBoxWidth = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
|
||||
local borderBoxHeight = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
|
||||
|
||||
-- Apply transform if exists
|
||||
local hasTransform = element.transform and self._Transform and not self._Transform.isIdentity(element.transform)
|
||||
if hasTransform then
|
||||
self._Transform.apply(element.transform, element.x, element.y, element.width, element.height)
|
||||
end
|
||||
|
||||
-- LAYER 0.5: Draw backdrop blur if configured (before background)
|
||||
if self.backdropBlur and self.backdropBlur.intensity > 0 and backdropCanvas then
|
||||
local blurInstance = self:getBlurInstance()
|
||||
@@ -367,6 +382,11 @@ function Renderer:draw(backdropCanvas)
|
||||
-- LAYER 3: Draw borders on top of theme
|
||||
self:_drawBorders(element.x, element.y, borderBoxWidth, borderBoxHeight)
|
||||
|
||||
-- Unapply transform if it was applied
|
||||
if hasTransform then
|
||||
self._Transform.unapply()
|
||||
end
|
||||
|
||||
-- Stop performance timing
|
||||
if Performance and Performance.isEnabled() and elementId then
|
||||
Performance.stopTimer("render_" .. elementId)
|
||||
|
||||
147
modules/Transform.lua
Normal file
147
modules/Transform.lua
Normal file
@@ -0,0 +1,147 @@
|
||||
--- Transform module for 2D transformations (rotate, scale, translate, skew)
|
||||
---@class Transform
|
||||
---@field rotate number? Rotation in radians (default: 0)
|
||||
---@field scaleX number? X-axis scale (default: 1)
|
||||
---@field scaleY number? Y-axis scale (default: 1)
|
||||
---@field translateX number? X translation in pixels (default: 0)
|
||||
---@field translateY number? Y translation in pixels (default: 0)
|
||||
---@field skewX number? X-axis skew in radians (default: 0)
|
||||
---@field skewY number? Y-axis skew in radians (default: 0)
|
||||
---@field originX number? Transform origin X (0-1, default: 0.5)
|
||||
---@field originY number? Transform origin Y (0-1, default: 0.5)
|
||||
local Transform = {}
|
||||
Transform.__index = Transform
|
||||
|
||||
--- Create a new transform instance
|
||||
---@param props TransformProps?
|
||||
---@return Transform transform
|
||||
function Transform.new(props)
|
||||
props = props or {}
|
||||
|
||||
local self = setmetatable({}, Transform)
|
||||
|
||||
self.rotate = props.rotate or 0
|
||||
self.scaleX = props.scaleX or 1
|
||||
self.scaleY = props.scaleY or 1
|
||||
self.translateX = props.translateX or 0
|
||||
self.translateY = props.translateY or 0
|
||||
self.skewX = props.skewX or 0
|
||||
self.skewY = props.skewY or 0
|
||||
self.originX = props.originX or 0.5
|
||||
self.originY = props.originY or 0.5
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
--- Apply transform to LÖVE graphics context
|
||||
---@param transform Transform Transform instance
|
||||
---@param x number Element x position
|
||||
---@param y number Element y position
|
||||
---@param width number Element width
|
||||
---@param height number Element height
|
||||
function Transform.apply(transform, x, y, width, height)
|
||||
if not transform then
|
||||
return
|
||||
end
|
||||
|
||||
-- Calculate transform origin
|
||||
local ox = x + width * transform.originX
|
||||
local oy = y + height * transform.originY
|
||||
|
||||
-- Apply transform in correct order: translate → rotate → scale → skew
|
||||
love.graphics.push()
|
||||
love.graphics.translate(ox, oy)
|
||||
|
||||
if transform.rotate ~= 0 then
|
||||
love.graphics.rotate(transform.rotate)
|
||||
end
|
||||
|
||||
if transform.scaleX ~= 1 or transform.scaleY ~= 1 then
|
||||
love.graphics.scale(transform.scaleX, transform.scaleY)
|
||||
end
|
||||
|
||||
if transform.skewX ~= 0 or transform.skewY ~= 0 then
|
||||
love.graphics.shear(transform.skewX, transform.skewY)
|
||||
end
|
||||
|
||||
love.graphics.translate(-ox, -oy)
|
||||
love.graphics.translate(transform.translateX, transform.translateY)
|
||||
end
|
||||
|
||||
--- Remove transform from LÖVE graphics context
|
||||
function Transform.unapply()
|
||||
love.graphics.pop()
|
||||
end
|
||||
|
||||
--- Interpolate between two transforms
|
||||
---@param from Transform Starting transform
|
||||
---@param to Transform Ending transform
|
||||
---@param t number Interpolation factor (0-1)
|
||||
---@return Transform interpolated
|
||||
function Transform.lerp(from, to, t)
|
||||
-- Sanitize inputs
|
||||
if type(from) ~= "table" then
|
||||
from = Transform.new()
|
||||
end
|
||||
if type(to) ~= "table" then
|
||||
to = Transform.new()
|
||||
end
|
||||
if type(t) ~= "number" or t ~= t or t == math.huge or t == -math.huge then
|
||||
t = 0
|
||||
end
|
||||
|
||||
-- Clamp t to 0-1 range
|
||||
t = math.max(0, math.min(1, t))
|
||||
|
||||
return Transform.new({
|
||||
rotate = (from.rotate or 0) * (1 - t) + (to.rotate or 0) * t,
|
||||
scaleX = (from.scaleX or 1) * (1 - t) + (to.scaleX or 1) * t,
|
||||
scaleY = (from.scaleY or 1) * (1 - t) + (to.scaleY or 1) * t,
|
||||
translateX = (from.translateX or 0) * (1 - t) + (to.translateX or 0) * t,
|
||||
translateY = (from.translateY or 0) * (1 - t) + (to.translateY or 0) * t,
|
||||
skewX = (from.skewX or 0) * (1 - t) + (to.skewX or 0) * t,
|
||||
skewY = (from.skewY or 0) * (1 - t) + (to.skewY or 0) * t,
|
||||
originX = (from.originX or 0.5) * (1 - t) + (to.originX or 0.5) * t,
|
||||
originY = (from.originY or 0.5) * (1 - t) + (to.originY or 0.5) * t,
|
||||
})
|
||||
end
|
||||
|
||||
--- Check if transform is identity (no transformation)
|
||||
---@param transform Transform
|
||||
---@return boolean isIdentity
|
||||
function Transform.isIdentity(transform)
|
||||
if not transform then
|
||||
return true
|
||||
end
|
||||
|
||||
return transform.rotate == 0
|
||||
and transform.scaleX == 1
|
||||
and transform.scaleY == 1
|
||||
and transform.translateX == 0
|
||||
and transform.translateY == 0
|
||||
and transform.skewX == 0
|
||||
and transform.skewY == 0
|
||||
end
|
||||
|
||||
--- Clone a transform
|
||||
---@param transform Transform
|
||||
---@return Transform clone
|
||||
function Transform.clone(transform)
|
||||
if not transform then
|
||||
return Transform.new()
|
||||
end
|
||||
|
||||
return Transform.new({
|
||||
rotate = transform.rotate,
|
||||
scaleX = transform.scaleX,
|
||||
scaleY = transform.scaleY,
|
||||
translateX = transform.translateX,
|
||||
translateY = transform.translateY,
|
||||
skewX = transform.skewX,
|
||||
skewY = transform.skewY,
|
||||
originX = transform.originX,
|
||||
originY = transform.originY,
|
||||
})
|
||||
end
|
||||
|
||||
return Transform
|
||||
@@ -125,3 +125,15 @@ local ElementProps = {}
|
||||
---@field bottom boolean
|
||||
---@field left boolean
|
||||
local Border = {}
|
||||
|
||||
---@class TransformProps
|
||||
---@field rotate number? Rotation in radians (default: 0)
|
||||
---@field scaleX number? X-axis scale (default: 1)
|
||||
---@field scaleY number? Y-axis scale (default: 1)
|
||||
---@field translateX number? X translation in pixels (default: 0)
|
||||
---@field translateY number? Y translation in pixels (default: 0)
|
||||
---@field skewX number? X-axis skew in radians (default: 0)
|
||||
---@field skewY number? Y-axis skew in radians (default: 0)
|
||||
---@field originX number? Transform origin X (0-1, default: 0.5)
|
||||
---@field originY number? Transform origin Y (0-1, default: 0.5)
|
||||
local TransformProps
|
||||
|
||||
@@ -64,6 +64,15 @@ local enums = {
|
||||
XL3 = "3xl",
|
||||
XL4 = "4xl",
|
||||
},
|
||||
---@enum ImageRepeat
|
||||
ImageRepeat = {
|
||||
NO_REPEAT = "no-repeat",
|
||||
REPEAT = "repeat",
|
||||
REPEAT_X = "repeat-x",
|
||||
REPEAT_Y = "repeat-y",
|
||||
SPACE = "space",
|
||||
ROUND = "round",
|
||||
},
|
||||
}
|
||||
|
||||
--- Get current keyboard modifiers state
|
||||
|
||||
@@ -89,7 +89,7 @@ echo -e "Contents:"
|
||||
echo " - FlexLove.lua"
|
||||
echo " - modules/ (27 files)"
|
||||
echo " - LICENSE"
|
||||
echo " - README.txt"
|
||||
echo " - README.md"
|
||||
echo ""
|
||||
echo -e "Files created:"
|
||||
echo " - $OUTPUT_FILE"
|
||||
|
||||
536
testing/__tests__/animation_properties_test.lua
Normal file
536
testing/__tests__/animation_properties_test.lua
Normal file
@@ -0,0 +1,536 @@
|
||||
local luaunit = require("testing.luaunit")
|
||||
require("testing.loveStub")
|
||||
|
||||
local Animation = require("modules.Animation")
|
||||
local Color = require("modules.Color")
|
||||
|
||||
TestAnimationProperties = {}
|
||||
|
||||
function TestAnimationProperties:setUp()
|
||||
-- Reset state before each test
|
||||
end
|
||||
|
||||
-- Test Color.lerp() method
|
||||
|
||||
function TestAnimationProperties:testColorLerp_MidPoint()
|
||||
local colorA = Color.new(0, 0, 0, 1) -- Black
|
||||
local colorB = Color.new(1, 1, 1, 1) -- White
|
||||
local result = Color.lerp(colorA, colorB, 0.5)
|
||||
|
||||
luaunit.assertAlmostEquals(result.r, 0.5, 0.01)
|
||||
luaunit.assertAlmostEquals(result.g, 0.5, 0.01)
|
||||
luaunit.assertAlmostEquals(result.b, 0.5, 0.01)
|
||||
luaunit.assertAlmostEquals(result.a, 1, 0.01)
|
||||
end
|
||||
|
||||
function TestAnimationProperties:testColorLerp_StartPoint()
|
||||
local colorA = Color.new(1, 0, 0, 1) -- Red
|
||||
local colorB = Color.new(0, 0, 1, 1) -- Blue
|
||||
local result = Color.lerp(colorA, colorB, 0)
|
||||
|
||||
luaunit.assertAlmostEquals(result.r, 1, 0.01)
|
||||
luaunit.assertAlmostEquals(result.g, 0, 0.01)
|
||||
luaunit.assertAlmostEquals(result.b, 0, 0.01)
|
||||
end
|
||||
|
||||
function TestAnimationProperties:testColorLerp_EndPoint()
|
||||
local colorA = Color.new(1, 0, 0, 1) -- Red
|
||||
local colorB = Color.new(0, 0, 1, 1) -- Blue
|
||||
local result = Color.lerp(colorA, colorB, 1)
|
||||
|
||||
luaunit.assertAlmostEquals(result.r, 0, 0.01)
|
||||
luaunit.assertAlmostEquals(result.g, 0, 0.01)
|
||||
luaunit.assertAlmostEquals(result.b, 1, 0.01)
|
||||
end
|
||||
|
||||
function TestAnimationProperties:testColorLerp_Alpha()
|
||||
local colorA = Color.new(1, 1, 1, 0) -- Transparent white
|
||||
local colorB = Color.new(1, 1, 1, 1) -- Opaque white
|
||||
local result = Color.lerp(colorA, colorB, 0.5)
|
||||
|
||||
luaunit.assertAlmostEquals(result.a, 0.5, 0.01)
|
||||
end
|
||||
|
||||
function TestAnimationProperties:testColorLerp_InvalidInputs()
|
||||
-- Should handle invalid inputs gracefully
|
||||
local result = Color.lerp("invalid", "invalid", 0.5)
|
||||
luaunit.assertNotNil(result)
|
||||
luaunit.assertEquals(getmetatable(result), Color)
|
||||
end
|
||||
|
||||
function TestAnimationProperties:testColorLerp_ClampT()
|
||||
local colorA = Color.new(0, 0, 0, 1)
|
||||
local colorB = Color.new(1, 1, 1, 1)
|
||||
|
||||
-- Test t > 1
|
||||
local result1 = Color.lerp(colorA, colorB, 1.5)
|
||||
luaunit.assertAlmostEquals(result1.r, 1, 0.01)
|
||||
|
||||
-- Test t < 0
|
||||
local result2 = Color.lerp(colorA, colorB, -0.5)
|
||||
luaunit.assertAlmostEquals(result2.r, 0, 0.01)
|
||||
end
|
||||
|
||||
-- Test Position Animation (x, y)
|
||||
|
||||
function TestAnimationProperties:testPositionAnimation_XProperty()
|
||||
local anim = Animation.new({
|
||||
duration = 1,
|
||||
start = { x = 0 },
|
||||
final = { x = 100 },
|
||||
})
|
||||
|
||||
anim:update(0.5)
|
||||
local result = anim:interpolate()
|
||||
|
||||
luaunit.assertAlmostEquals(result.x, 50, 0.01)
|
||||
end
|
||||
|
||||
function TestAnimationProperties:testPositionAnimation_YProperty()
|
||||
local anim = Animation.new({
|
||||
duration = 1,
|
||||
start = { y = 0 },
|
||||
final = { y = 200 },
|
||||
})
|
||||
|
||||
anim:update(0.5)
|
||||
local result = anim:interpolate()
|
||||
|
||||
luaunit.assertAlmostEquals(result.y, 100, 0.01)
|
||||
end
|
||||
|
||||
function TestAnimationProperties:testPositionAnimation_XY()
|
||||
local anim = Animation.new({
|
||||
duration = 1,
|
||||
start = { x = 10, y = 20 },
|
||||
final = { x = 110, y = 220 },
|
||||
})
|
||||
|
||||
anim:update(0.5)
|
||||
local result = anim:interpolate()
|
||||
|
||||
luaunit.assertAlmostEquals(result.x, 60, 0.01)
|
||||
luaunit.assertAlmostEquals(result.y, 120, 0.01)
|
||||
end
|
||||
|
||||
-- Test Color Property Animation
|
||||
|
||||
function TestAnimationProperties:testColorAnimation_BackgroundColor()
|
||||
local anim = Animation.new({
|
||||
duration = 1,
|
||||
start = { backgroundColor = Color.new(1, 0, 0, 1) }, -- Red
|
||||
final = { backgroundColor = Color.new(0, 0, 1, 1) }, -- Blue
|
||||
})
|
||||
anim:setColorModule(Color)
|
||||
|
||||
anim:update(0.5)
|
||||
local result = anim:interpolate()
|
||||
|
||||
luaunit.assertNotNil(result.backgroundColor)
|
||||
luaunit.assertAlmostEquals(result.backgroundColor.r, 0.5, 0.01)
|
||||
luaunit.assertAlmostEquals(result.backgroundColor.b, 0.5, 0.01)
|
||||
end
|
||||
|
||||
function TestAnimationProperties:testColorAnimation_MultipleColors()
|
||||
local anim = Animation.new({
|
||||
duration = 1,
|
||||
start = {
|
||||
backgroundColor = Color.new(1, 0, 0, 1),
|
||||
borderColor = Color.new(0, 1, 0, 1),
|
||||
textColor = Color.new(0, 0, 1, 1),
|
||||
},
|
||||
final = {
|
||||
backgroundColor = Color.new(0, 1, 0, 1),
|
||||
borderColor = Color.new(0, 0, 1, 1),
|
||||
textColor = Color.new(1, 0, 0, 1),
|
||||
},
|
||||
})
|
||||
anim:setColorModule(Color)
|
||||
|
||||
anim:update(0.5)
|
||||
local result = anim:interpolate()
|
||||
|
||||
luaunit.assertNotNil(result.backgroundColor)
|
||||
luaunit.assertNotNil(result.borderColor)
|
||||
luaunit.assertNotNil(result.textColor)
|
||||
|
||||
-- Mid-point should be (0.5, 0.5, 0.5) for backgroundColor
|
||||
luaunit.assertAlmostEquals(result.backgroundColor.r, 0.5, 0.01)
|
||||
luaunit.assertAlmostEquals(result.backgroundColor.g, 0.5, 0.01)
|
||||
end
|
||||
|
||||
function TestAnimationProperties:testColorAnimation_WithoutColorModule()
|
||||
-- Should not interpolate colors without Color module set
|
||||
local anim = Animation.new({
|
||||
duration = 1,
|
||||
start = { backgroundColor = Color.new(1, 0, 0, 1) },
|
||||
final = { backgroundColor = Color.new(0, 0, 1, 1) },
|
||||
})
|
||||
-- Don't set Color module
|
||||
|
||||
anim:update(0.5)
|
||||
local result = anim:interpolate()
|
||||
|
||||
luaunit.assertNil(result.backgroundColor)
|
||||
end
|
||||
|
||||
function TestAnimationProperties:testColorAnimation_HexColors()
|
||||
local anim = Animation.new({
|
||||
duration = 1,
|
||||
start = { backgroundColor = "#FF0000" }, -- Red
|
||||
final = { backgroundColor = "#0000FF" }, -- Blue
|
||||
})
|
||||
anim:setColorModule(Color)
|
||||
|
||||
anim:update(0.5)
|
||||
local result = anim:interpolate()
|
||||
|
||||
luaunit.assertNotNil(result.backgroundColor)
|
||||
luaunit.assertAlmostEquals(result.backgroundColor.r, 0.5, 0.01)
|
||||
end
|
||||
|
||||
function TestAnimationProperties:testColorAnimation_NamedColors()
|
||||
local anim = Animation.new({
|
||||
duration = 1,
|
||||
start = { backgroundColor = "red" },
|
||||
final = { backgroundColor = "blue" },
|
||||
})
|
||||
anim:setColorModule(Color)
|
||||
|
||||
anim:update(0.5)
|
||||
local result = anim:interpolate()
|
||||
|
||||
luaunit.assertNotNil(result.backgroundColor)
|
||||
luaunit.assertAlmostEquals(result.backgroundColor.r, 0.5, 0.01)
|
||||
end
|
||||
|
||||
-- Test Numeric Property Animation
|
||||
|
||||
function TestAnimationProperties:testNumericAnimation_Gap()
|
||||
local anim = Animation.new({
|
||||
duration = 1,
|
||||
start = { gap = 0 },
|
||||
final = { gap = 20 },
|
||||
})
|
||||
|
||||
anim:update(0.5)
|
||||
local result = anim:interpolate()
|
||||
|
||||
luaunit.assertAlmostEquals(result.gap, 10, 0.01)
|
||||
end
|
||||
|
||||
function TestAnimationProperties:testNumericAnimation_ImageOpacity()
|
||||
local anim = Animation.new({
|
||||
duration = 1,
|
||||
start = { imageOpacity = 0 },
|
||||
final = { imageOpacity = 1 },
|
||||
})
|
||||
|
||||
anim:update(0.5)
|
||||
local result = anim:interpolate()
|
||||
|
||||
luaunit.assertAlmostEquals(result.imageOpacity, 0.5, 0.01)
|
||||
end
|
||||
|
||||
function TestAnimationProperties:testNumericAnimation_BorderWidth()
|
||||
local anim = Animation.new({
|
||||
duration = 1,
|
||||
start = { borderWidth = 1 },
|
||||
final = { borderWidth = 10 },
|
||||
})
|
||||
|
||||
anim:update(0.5)
|
||||
local result = anim:interpolate()
|
||||
|
||||
luaunit.assertAlmostEquals(result.borderWidth, 5.5, 0.01)
|
||||
end
|
||||
|
||||
function TestAnimationProperties:testNumericAnimation_FontSize()
|
||||
local anim = Animation.new({
|
||||
duration = 1,
|
||||
start = { fontSize = 12 },
|
||||
final = { fontSize = 24 },
|
||||
})
|
||||
|
||||
anim:update(0.5)
|
||||
local result = anim:interpolate()
|
||||
|
||||
luaunit.assertAlmostEquals(result.fontSize, 18, 0.01)
|
||||
end
|
||||
|
||||
function TestAnimationProperties:testNumericAnimation_MultipleProperties()
|
||||
local anim = Animation.new({
|
||||
duration = 1,
|
||||
start = { gap = 0, imageOpacity = 0, borderWidth = 1 },
|
||||
final = { gap = 20, imageOpacity = 1, borderWidth = 5 },
|
||||
})
|
||||
|
||||
anim:update(0.5)
|
||||
local result = anim:interpolate()
|
||||
|
||||
luaunit.assertAlmostEquals(result.gap, 10, 0.01)
|
||||
luaunit.assertAlmostEquals(result.imageOpacity, 0.5, 0.01)
|
||||
luaunit.assertAlmostEquals(result.borderWidth, 3, 0.01)
|
||||
end
|
||||
|
||||
-- Test Table Property Animation (padding, margin, cornerRadius)
|
||||
|
||||
function TestAnimationProperties:testTableAnimation_Padding()
|
||||
local anim = Animation.new({
|
||||
duration = 1,
|
||||
start = { padding = { top = 0, right = 0, bottom = 0, left = 0 } },
|
||||
final = { padding = { top = 10, right = 20, bottom = 10, left = 20 } },
|
||||
})
|
||||
|
||||
anim:update(0.5)
|
||||
local result = anim:interpolate()
|
||||
|
||||
luaunit.assertNotNil(result.padding)
|
||||
luaunit.assertAlmostEquals(result.padding.top, 5, 0.01)
|
||||
luaunit.assertAlmostEquals(result.padding.right, 10, 0.01)
|
||||
luaunit.assertAlmostEquals(result.padding.bottom, 5, 0.01)
|
||||
luaunit.assertAlmostEquals(result.padding.left, 10, 0.01)
|
||||
end
|
||||
|
||||
function TestAnimationProperties:testTableAnimation_Margin()
|
||||
local anim = Animation.new({
|
||||
duration = 1,
|
||||
start = { margin = { top = 0, right = 0, bottom = 0, left = 0 } },
|
||||
final = { margin = { top = 20, right = 20, bottom = 20, left = 20 } },
|
||||
})
|
||||
|
||||
anim:update(0.5)
|
||||
local result = anim:interpolate()
|
||||
|
||||
luaunit.assertNotNil(result.margin)
|
||||
luaunit.assertAlmostEquals(result.margin.top, 10, 0.01)
|
||||
luaunit.assertAlmostEquals(result.margin.right, 10, 0.01)
|
||||
end
|
||||
|
||||
function TestAnimationProperties:testTableAnimation_CornerRadius()
|
||||
local anim = Animation.new({
|
||||
duration = 1,
|
||||
start = { cornerRadius = { topLeft = 0, topRight = 0, bottomLeft = 0, bottomRight = 0 } },
|
||||
final = { cornerRadius = { topLeft = 10, topRight = 10, bottomLeft = 10, bottomRight = 10 } },
|
||||
})
|
||||
|
||||
anim:update(0.5)
|
||||
local result = anim:interpolate()
|
||||
|
||||
luaunit.assertNotNil(result.cornerRadius)
|
||||
luaunit.assertAlmostEquals(result.cornerRadius.topLeft, 5, 0.01)
|
||||
luaunit.assertAlmostEquals(result.cornerRadius.topRight, 5, 0.01)
|
||||
end
|
||||
|
||||
function TestAnimationProperties:testTableAnimation_PartialKeys()
|
||||
-- Test when start and final have different keys
|
||||
local anim = Animation.new({
|
||||
duration = 1,
|
||||
start = { padding = { top = 0, left = 0 } },
|
||||
final = { padding = { top = 10, right = 20, left = 10 } },
|
||||
})
|
||||
|
||||
anim:update(0.5)
|
||||
local result = anim:interpolate()
|
||||
|
||||
luaunit.assertNotNil(result.padding)
|
||||
luaunit.assertAlmostEquals(result.padding.top, 5, 0.01)
|
||||
luaunit.assertAlmostEquals(result.padding.left, 5, 0.01)
|
||||
luaunit.assertNotNil(result.padding.right)
|
||||
end
|
||||
|
||||
function TestAnimationProperties:testTableAnimation_NonNumericValues()
|
||||
-- Should skip non-numeric values in tables
|
||||
local anim = Animation.new({
|
||||
duration = 1,
|
||||
start = { padding = { top = 0, special = "value" } },
|
||||
final = { padding = { top = 10, special = "value" } },
|
||||
})
|
||||
|
||||
anim:update(0.5)
|
||||
local result = anim:interpolate()
|
||||
|
||||
luaunit.assertNotNil(result.padding)
|
||||
luaunit.assertAlmostEquals(result.padding.top, 5, 0.01)
|
||||
end
|
||||
|
||||
-- Test Combined Animations
|
||||
|
||||
function TestAnimationProperties:testCombinedAnimation_AllTypes()
|
||||
local anim = Animation.new({
|
||||
duration = 1,
|
||||
start = {
|
||||
width = 100,
|
||||
height = 100,
|
||||
x = 0,
|
||||
y = 0,
|
||||
opacity = 0,
|
||||
backgroundColor = Color.new(1, 0, 0, 1),
|
||||
gap = 0,
|
||||
padding = { top = 0, left = 0 },
|
||||
},
|
||||
final = {
|
||||
width = 200,
|
||||
height = 200,
|
||||
x = 100,
|
||||
y = 100,
|
||||
opacity = 1,
|
||||
backgroundColor = Color.new(0, 0, 1, 1),
|
||||
gap = 20,
|
||||
padding = { top = 10, left = 10 },
|
||||
},
|
||||
})
|
||||
anim:setColorModule(Color)
|
||||
|
||||
anim:update(0.5)
|
||||
local result = anim:interpolate()
|
||||
|
||||
-- Check all properties interpolated correctly
|
||||
luaunit.assertAlmostEquals(result.width, 150, 0.01)
|
||||
luaunit.assertAlmostEquals(result.height, 150, 0.01)
|
||||
luaunit.assertAlmostEquals(result.x, 50, 0.01)
|
||||
luaunit.assertAlmostEquals(result.y, 50, 0.01)
|
||||
luaunit.assertAlmostEquals(result.opacity, 0.5, 0.01)
|
||||
luaunit.assertAlmostEquals(result.gap, 10, 0.01)
|
||||
luaunit.assertNotNil(result.backgroundColor)
|
||||
luaunit.assertNotNil(result.padding)
|
||||
end
|
||||
|
||||
function TestAnimationProperties:testCombinedAnimation_WithEasing()
|
||||
local anim = Animation.new({
|
||||
duration = 1,
|
||||
start = { x = 0, backgroundColor = Color.new(0, 0, 0, 1) },
|
||||
final = { x = 100, backgroundColor = Color.new(1, 1, 1, 1) },
|
||||
easing = "easeInQuad",
|
||||
})
|
||||
anim:setColorModule(Color)
|
||||
|
||||
anim:update(0.5)
|
||||
local result = anim:interpolate()
|
||||
|
||||
-- With easeInQuad, at t=0.5, eased value should be 0.25
|
||||
luaunit.assertAlmostEquals(result.x, 25, 0.01)
|
||||
luaunit.assertAlmostEquals(result.backgroundColor.r, 0.25, 0.01)
|
||||
end
|
||||
|
||||
-- Test Backward Compatibility
|
||||
|
||||
function TestAnimationProperties:testBackwardCompatibility_WidthHeightOpacity()
|
||||
-- Ensure old animations still work
|
||||
local anim = Animation.new({
|
||||
duration = 1,
|
||||
start = { width = 100, height = 100, opacity = 0 },
|
||||
final = { width = 200, height = 200, opacity = 1 },
|
||||
})
|
||||
|
||||
anim:update(0.5)
|
||||
local result = anim:interpolate()
|
||||
|
||||
luaunit.assertAlmostEquals(result.width, 150, 0.01)
|
||||
luaunit.assertAlmostEquals(result.height, 150, 0.01)
|
||||
luaunit.assertAlmostEquals(result.opacity, 0.5, 0.01)
|
||||
end
|
||||
|
||||
function TestAnimationProperties:testBackwardCompatibility_FadeHelper()
|
||||
local anim = Animation.fade(1, 0, 1)
|
||||
|
||||
anim:update(0.5)
|
||||
local result = anim:interpolate()
|
||||
|
||||
luaunit.assertAlmostEquals(result.opacity, 0.5, 0.01)
|
||||
end
|
||||
|
||||
function TestAnimationProperties:testBackwardCompatibility_ScaleHelper()
|
||||
local anim = Animation.scale(1, { width = 100, height = 100 }, { width = 200, height = 200 })
|
||||
|
||||
anim:update(0.5)
|
||||
local result = anim:interpolate()
|
||||
|
||||
luaunit.assertAlmostEquals(result.width, 150, 0.01)
|
||||
luaunit.assertAlmostEquals(result.height, 150, 0.01)
|
||||
end
|
||||
|
||||
-- Test Edge Cases
|
||||
|
||||
function TestAnimationProperties:testEdgeCase_MissingStartValue()
|
||||
local anim = Animation.new({
|
||||
duration = 1,
|
||||
start = { x = 0 },
|
||||
final = { x = 100, y = 100 },
|
||||
})
|
||||
|
||||
anim:update(0.5)
|
||||
local result = anim:interpolate()
|
||||
|
||||
luaunit.assertAlmostEquals(result.x, 50, 0.01)
|
||||
luaunit.assertNil(result.y) -- Should be nil since start.y is missing
|
||||
end
|
||||
|
||||
function TestAnimationProperties:testEdgeCase_MissingFinalValue()
|
||||
local anim = Animation.new({
|
||||
duration = 1,
|
||||
start = { x = 0, y = 0 },
|
||||
final = { x = 100 },
|
||||
})
|
||||
|
||||
anim:update(0.5)
|
||||
local result = anim:interpolate()
|
||||
|
||||
luaunit.assertAlmostEquals(result.x, 50, 0.01)
|
||||
luaunit.assertNil(result.y) -- Should be nil since final.y is missing
|
||||
end
|
||||
|
||||
function TestAnimationProperties:testEdgeCase_EmptyTables()
|
||||
local anim = Animation.new({
|
||||
duration = 1,
|
||||
start = {},
|
||||
final = {},
|
||||
})
|
||||
|
||||
anim:update(0.5)
|
||||
local result = anim:interpolate()
|
||||
|
||||
-- Should not error, just return empty result
|
||||
luaunit.assertNotNil(result)
|
||||
end
|
||||
|
||||
function TestAnimationProperties:testEdgeCase_CachedResult()
|
||||
-- Test that cached results work correctly
|
||||
local anim = Animation.new({
|
||||
duration = 1,
|
||||
start = { x = 0 },
|
||||
final = { x = 100 },
|
||||
})
|
||||
|
||||
anim:update(0.5)
|
||||
local result1 = anim:interpolate()
|
||||
local result2 = anim:interpolate() -- Should use cached result
|
||||
|
||||
luaunit.assertEquals(result1, result2) -- Same table reference
|
||||
luaunit.assertAlmostEquals(result1.x, 50, 0.01)
|
||||
end
|
||||
|
||||
function TestAnimationProperties:testEdgeCase_ResultInvalidatedOnUpdate()
|
||||
local anim = Animation.new({
|
||||
duration = 1,
|
||||
start = { x = 0 },
|
||||
final = { x = 100 },
|
||||
})
|
||||
|
||||
anim:update(0.5)
|
||||
local result1 = anim:interpolate()
|
||||
local x1 = result1.x -- Store value, not reference
|
||||
|
||||
anim:update(0.25) -- Update again
|
||||
local result2 = anim:interpolate()
|
||||
local x2 = result2.x
|
||||
|
||||
-- Should recalculate
|
||||
-- Note: result1 and result2 are the same cached table, but values should be updated
|
||||
luaunit.assertAlmostEquals(x1, 50, 0.01)
|
||||
luaunit.assertAlmostEquals(x2, 75, 0.01)
|
||||
-- result1.x will actually be 75 now since it's the same table reference
|
||||
luaunit.assertAlmostEquals(result1.x, 75, 0.01)
|
||||
end
|
||||
|
||||
os.exit(luaunit.LuaUnit.run())
|
||||
405
testing/__tests__/image_tiling_test.lua
Normal file
405
testing/__tests__/image_tiling_test.lua
Normal file
@@ -0,0 +1,405 @@
|
||||
-- Image Tiling Tests
|
||||
-- Tests for ImageRenderer tiling functionality
|
||||
|
||||
local luaunit = require("testing.luaunit")
|
||||
require("testing.loveStub")
|
||||
|
||||
local ImageRenderer = require("modules.ImageRenderer")
|
||||
local ErrorHandler = require("modules.ErrorHandler")
|
||||
local Color = require("modules.Color")
|
||||
|
||||
-- Initialize ImageRenderer with ErrorHandler
|
||||
ImageRenderer.init({ ErrorHandler = ErrorHandler })
|
||||
|
||||
TestImageTiling = {}
|
||||
|
||||
function TestImageTiling:setUp()
|
||||
-- Create a mock image
|
||||
self.mockImage = {
|
||||
getDimensions = function() return 64, 64 end,
|
||||
type = function() return "Image" end,
|
||||
}
|
||||
end
|
||||
|
||||
function TestImageTiling:tearDown()
|
||||
self.mockImage = nil
|
||||
end
|
||||
|
||||
function TestImageTiling:testDrawTiledNoRepeat()
|
||||
-- Test no-repeat mode (single image)
|
||||
local drawCalls = {}
|
||||
local originalDraw = love.graphics.draw
|
||||
love.graphics.draw = function(...)
|
||||
table.insert(drawCalls, {...})
|
||||
end
|
||||
|
||||
ImageRenderer.drawTiled(self.mockImage, 100, 100, 200, 200, "no-repeat", 1, nil)
|
||||
|
||||
-- Should draw once
|
||||
luaunit.assertEquals(#drawCalls, 1)
|
||||
luaunit.assertEquals(drawCalls[1][1], self.mockImage)
|
||||
luaunit.assertEquals(drawCalls[1][2], 100)
|
||||
luaunit.assertEquals(drawCalls[1][3], 100)
|
||||
|
||||
love.graphics.draw = originalDraw
|
||||
end
|
||||
|
||||
function TestImageTiling:testDrawTiledRepeat()
|
||||
-- Test repeat mode (tiles in both directions)
|
||||
local drawCalls = {}
|
||||
local originalDraw = love.graphics.draw
|
||||
local originalNewQuad = love.graphics.newQuad
|
||||
|
||||
love.graphics.draw = function(...)
|
||||
table.insert(drawCalls, {...})
|
||||
end
|
||||
|
||||
love.graphics.newQuad = function(...)
|
||||
return { type = "quad", ... }
|
||||
end
|
||||
|
||||
-- Image is 64x64, bounds are 200x200
|
||||
-- Should tile 4 times (4 tiles total: 2x2 with partials)
|
||||
ImageRenderer.drawTiled(self.mockImage, 100, 100, 200, 200, "repeat", 1, nil)
|
||||
|
||||
-- 4 tiles: (0,0), (64,0), (0,64), (64,64)
|
||||
-- 2 full tiles + 2 partial tiles = 4 draws
|
||||
luaunit.assertTrue(#drawCalls >= 4)
|
||||
|
||||
love.graphics.draw = originalDraw
|
||||
love.graphics.newQuad = originalNewQuad
|
||||
end
|
||||
|
||||
function TestImageTiling:testDrawTiledRepeatX()
|
||||
-- Test repeat-x mode (tiles horizontally only)
|
||||
local drawCalls = {}
|
||||
local originalDraw = love.graphics.draw
|
||||
local originalNewQuad = love.graphics.newQuad
|
||||
|
||||
love.graphics.draw = function(...)
|
||||
table.insert(drawCalls, {...})
|
||||
end
|
||||
|
||||
love.graphics.newQuad = function(...)
|
||||
return { type = "quad", ... }
|
||||
end
|
||||
|
||||
-- Image is 64x64, bounds are 200x64
|
||||
-- Should tile 4 times horizontally: (0), (64), (128), (192)
|
||||
ImageRenderer.drawTiled(self.mockImage, 100, 100, 200, 64, "repeat-x", 1, nil)
|
||||
|
||||
-- 3 full tiles + 1 partial tile = 4 draws
|
||||
luaunit.assertTrue(#drawCalls >= 3)
|
||||
|
||||
love.graphics.draw = originalDraw
|
||||
love.graphics.newQuad = originalNewQuad
|
||||
end
|
||||
|
||||
function TestImageTiling:testDrawTiledRepeatY()
|
||||
-- Test repeat-y mode (tiles vertically only)
|
||||
local drawCalls = {}
|
||||
local originalDraw = love.graphics.draw
|
||||
local originalNewQuad = love.graphics.newQuad
|
||||
|
||||
love.graphics.draw = function(...)
|
||||
table.insert(drawCalls, {...})
|
||||
end
|
||||
|
||||
love.graphics.newQuad = function(...)
|
||||
return { type = "quad", ... }
|
||||
end
|
||||
|
||||
-- Image is 64x64, bounds are 64x200
|
||||
-- Should tile 4 times vertically
|
||||
ImageRenderer.drawTiled(self.mockImage, 100, 100, 64, 200, "repeat-y", 1, nil)
|
||||
|
||||
-- 3 full tiles + 1 partial tile = 4 draws
|
||||
luaunit.assertTrue(#drawCalls >= 3)
|
||||
|
||||
love.graphics.draw = originalDraw
|
||||
love.graphics.newQuad = originalNewQuad
|
||||
end
|
||||
|
||||
function TestImageTiling:testDrawTiledSpace()
|
||||
-- Test space mode (distributes tiles with even spacing)
|
||||
local drawCalls = {}
|
||||
local originalDraw = love.graphics.draw
|
||||
|
||||
love.graphics.draw = function(...)
|
||||
table.insert(drawCalls, {...})
|
||||
end
|
||||
|
||||
-- Image is 64x64, bounds are 200x200
|
||||
ImageRenderer.drawTiled(self.mockImage, 100, 100, 200, 200, "space", 1, nil)
|
||||
|
||||
-- Should draw multiple tiles with spacing
|
||||
luaunit.assertTrue(#drawCalls > 1)
|
||||
|
||||
love.graphics.draw = originalDraw
|
||||
end
|
||||
|
||||
function TestImageTiling:testDrawTiledRound()
|
||||
-- Test round mode (scales tiles to fit exactly)
|
||||
local drawCalls = {}
|
||||
local originalDraw = love.graphics.draw
|
||||
|
||||
love.graphics.draw = function(...)
|
||||
table.insert(drawCalls, {...})
|
||||
end
|
||||
|
||||
-- Image is 64x64, bounds are 200x200
|
||||
ImageRenderer.drawTiled(self.mockImage, 100, 100, 200, 200, "round", 1, nil)
|
||||
|
||||
-- Should draw tiles with scaling
|
||||
luaunit.assertTrue(#drawCalls > 1)
|
||||
|
||||
love.graphics.draw = originalDraw
|
||||
end
|
||||
|
||||
function TestImageTiling:testDrawTiledWithOpacity()
|
||||
-- Test tiling with opacity
|
||||
local setColorCalls = {}
|
||||
local originalSetColor = love.graphics.setColor
|
||||
|
||||
love.graphics.setColor = function(...)
|
||||
table.insert(setColorCalls, {...})
|
||||
end
|
||||
|
||||
ImageRenderer.drawTiled(self.mockImage, 100, 100, 200, 200, "no-repeat", 0.5, nil)
|
||||
|
||||
-- Should set color with opacity
|
||||
luaunit.assertTrue(#setColorCalls > 0)
|
||||
-- Check that opacity 0.5 was used
|
||||
local found = false
|
||||
for _, call in ipairs(setColorCalls) do
|
||||
if call[4] == 0.5 then
|
||||
found = true
|
||||
break
|
||||
end
|
||||
end
|
||||
luaunit.assertTrue(found)
|
||||
|
||||
love.graphics.setColor = originalSetColor
|
||||
end
|
||||
|
||||
function TestImageTiling:testDrawTiledWithTint()
|
||||
-- Test tiling with tint color
|
||||
local setColorCalls = {}
|
||||
local originalSetColor = love.graphics.setColor
|
||||
|
||||
love.graphics.setColor = function(...)
|
||||
table.insert(setColorCalls, {...})
|
||||
end
|
||||
|
||||
local redTint = Color.new(1, 0, 0, 1)
|
||||
ImageRenderer.drawTiled(self.mockImage, 100, 100, 200, 200, "no-repeat", 1, redTint)
|
||||
|
||||
-- Should set color with tint
|
||||
luaunit.assertTrue(#setColorCalls > 0)
|
||||
-- Check that red tint was used (r=1, g=0, b=0)
|
||||
local found = false
|
||||
for _, call in ipairs(setColorCalls) do
|
||||
if call[1] == 1 and call[2] == 0 and call[3] == 0 then
|
||||
found = true
|
||||
break
|
||||
end
|
||||
end
|
||||
luaunit.assertTrue(found)
|
||||
|
||||
love.graphics.setColor = originalSetColor
|
||||
end
|
||||
|
||||
function TestImageTiling:testElementImageRepeatProperty()
|
||||
-- Test that Element accepts imageRepeat property
|
||||
local Element = require("modules.Element")
|
||||
local utils = require("modules.utils")
|
||||
local Color = require("modules.Color")
|
||||
local Units = require("modules.Units")
|
||||
local LayoutEngine = require("modules.LayoutEngine")
|
||||
local Renderer = require("modules.Renderer")
|
||||
local EventHandler = require("modules.EventHandler")
|
||||
local ImageCache = require("modules.ImageCache")
|
||||
|
||||
local deps = {
|
||||
utils = utils,
|
||||
Color = Color,
|
||||
Units = Units,
|
||||
LayoutEngine = LayoutEngine,
|
||||
Renderer = Renderer,
|
||||
EventHandler = EventHandler,
|
||||
ImageCache = ImageCache,
|
||||
ImageRenderer = ImageRenderer,
|
||||
ErrorHandler = ErrorHandler,
|
||||
}
|
||||
|
||||
local element = Element.new({
|
||||
width = 200,
|
||||
height = 200,
|
||||
imageRepeat = "repeat",
|
||||
}, deps)
|
||||
|
||||
luaunit.assertEquals(element.imageRepeat, "repeat")
|
||||
end
|
||||
|
||||
function TestImageTiling:testElementImageRepeatDefault()
|
||||
-- Test that imageRepeat defaults to "no-repeat"
|
||||
local Element = require("modules.Element")
|
||||
local utils = require("modules.utils")
|
||||
local Color = require("modules.Color")
|
||||
local Units = require("modules.Units")
|
||||
local LayoutEngine = require("modules.LayoutEngine")
|
||||
local Renderer = require("modules.Renderer")
|
||||
local EventHandler = require("modules.EventHandler")
|
||||
local ImageCache = require("modules.ImageCache")
|
||||
|
||||
local deps = {
|
||||
utils = utils,
|
||||
Color = Color,
|
||||
Units = Units,
|
||||
LayoutEngine = LayoutEngine,
|
||||
Renderer = Renderer,
|
||||
EventHandler = EventHandler,
|
||||
ImageCache = ImageCache,
|
||||
ImageRenderer = ImageRenderer,
|
||||
ErrorHandler = ErrorHandler,
|
||||
}
|
||||
|
||||
local element = Element.new({
|
||||
width = 200,
|
||||
height = 200,
|
||||
}, deps)
|
||||
|
||||
luaunit.assertEquals(element.imageRepeat, "no-repeat")
|
||||
end
|
||||
|
||||
function TestImageTiling:testElementSetImageRepeat()
|
||||
-- Test setImageRepeat method
|
||||
local Element = require("modules.Element")
|
||||
local utils = require("modules.utils")
|
||||
local Color = require("modules.Color")
|
||||
local Units = require("modules.Units")
|
||||
local LayoutEngine = require("modules.LayoutEngine")
|
||||
local Renderer = require("modules.Renderer")
|
||||
local EventHandler = require("modules.EventHandler")
|
||||
local ImageCache = require("modules.ImageCache")
|
||||
|
||||
local deps = {
|
||||
utils = utils,
|
||||
Color = Color,
|
||||
Units = Units,
|
||||
LayoutEngine = LayoutEngine,
|
||||
Renderer = Renderer,
|
||||
EventHandler = EventHandler,
|
||||
ImageCache = ImageCache,
|
||||
ImageRenderer = ImageRenderer,
|
||||
ErrorHandler = ErrorHandler,
|
||||
}
|
||||
|
||||
local element = Element.new({
|
||||
width = 200,
|
||||
height = 200,
|
||||
}, deps)
|
||||
|
||||
element:setImageRepeat("repeat-x")
|
||||
luaunit.assertEquals(element.imageRepeat, "repeat-x")
|
||||
end
|
||||
|
||||
function TestImageTiling:testElementImageTintProperty()
|
||||
-- Test that Element accepts imageTint property
|
||||
local Element = require("modules.Element")
|
||||
local utils = require("modules.utils")
|
||||
local Units = require("modules.Units")
|
||||
local LayoutEngine = require("modules.LayoutEngine")
|
||||
local Renderer = require("modules.Renderer")
|
||||
local EventHandler = require("modules.EventHandler")
|
||||
local ImageCache = require("modules.ImageCache")
|
||||
|
||||
local redTint = Color.new(1, 0, 0, 1)
|
||||
|
||||
local deps = {
|
||||
utils = utils,
|
||||
Color = Color,
|
||||
Units = Units,
|
||||
LayoutEngine = LayoutEngine,
|
||||
Renderer = Renderer,
|
||||
EventHandler = EventHandler,
|
||||
ImageCache = ImageCache,
|
||||
ImageRenderer = ImageRenderer,
|
||||
ErrorHandler = ErrorHandler,
|
||||
}
|
||||
|
||||
local element = Element.new({
|
||||
width = 200,
|
||||
height = 200,
|
||||
imageTint = redTint,
|
||||
}, deps)
|
||||
|
||||
luaunit.assertEquals(element.imageTint, redTint)
|
||||
end
|
||||
|
||||
function TestImageTiling:testElementSetImageTint()
|
||||
-- Test setImageTint method
|
||||
local Element = require("modules.Element")
|
||||
local utils = require("modules.utils")
|
||||
local Units = require("modules.Units")
|
||||
local LayoutEngine = require("modules.LayoutEngine")
|
||||
local Renderer = require("modules.Renderer")
|
||||
local EventHandler = require("modules.EventHandler")
|
||||
local ImageCache = require("modules.ImageCache")
|
||||
|
||||
local deps = {
|
||||
utils = utils,
|
||||
Color = Color,
|
||||
Units = Units,
|
||||
LayoutEngine = LayoutEngine,
|
||||
Renderer = Renderer,
|
||||
EventHandler = EventHandler,
|
||||
ImageCache = ImageCache,
|
||||
ImageRenderer = ImageRenderer,
|
||||
ErrorHandler = ErrorHandler,
|
||||
}
|
||||
|
||||
local element = Element.new({
|
||||
width = 200,
|
||||
height = 200,
|
||||
}, deps)
|
||||
|
||||
local blueTint = Color.new(0, 0, 1, 1)
|
||||
element:setImageTint(blueTint)
|
||||
luaunit.assertEquals(element.imageTint, blueTint)
|
||||
end
|
||||
|
||||
function TestImageTiling:testElementSetImageOpacity()
|
||||
-- Test setImageOpacity method
|
||||
local Element = require("modules.Element")
|
||||
local utils = require("modules.utils")
|
||||
local Color = require("modules.Color")
|
||||
local Units = require("modules.Units")
|
||||
local LayoutEngine = require("modules.LayoutEngine")
|
||||
local Renderer = require("modules.Renderer")
|
||||
local EventHandler = require("modules.EventHandler")
|
||||
local ImageCache = require("modules.ImageCache")
|
||||
|
||||
local deps = {
|
||||
utils = utils,
|
||||
Color = Color,
|
||||
Units = Units,
|
||||
LayoutEngine = LayoutEngine,
|
||||
Renderer = Renderer,
|
||||
EventHandler = EventHandler,
|
||||
ImageCache = ImageCache,
|
||||
ImageRenderer = ImageRenderer,
|
||||
ErrorHandler = ErrorHandler,
|
||||
}
|
||||
|
||||
local element = Element.new({
|
||||
width = 200,
|
||||
height = 200,
|
||||
}, deps)
|
||||
|
||||
element:setImageOpacity(0.7)
|
||||
luaunit.assertEquals(element.imageOpacity, 0.7)
|
||||
end
|
||||
|
||||
-- Run the tests
|
||||
os.exit(luaunit.LuaUnit.run())
|
||||
292
testing/__tests__/transform_test.lua
Normal file
292
testing/__tests__/transform_test.lua
Normal file
@@ -0,0 +1,292 @@
|
||||
local luaunit = require("testing.luaunit")
|
||||
require("testing.loveStub")
|
||||
|
||||
local Transform = require("modules.Transform")
|
||||
|
||||
TestTransform = {}
|
||||
|
||||
function TestTransform:setUp()
|
||||
-- Reset state before each test
|
||||
end
|
||||
|
||||
-- Test Transform.new()
|
||||
|
||||
function TestTransform:testNew_DefaultValues()
|
||||
local transform = Transform.new()
|
||||
|
||||
luaunit.assertNotNil(transform)
|
||||
luaunit.assertEquals(transform.rotate, 0)
|
||||
luaunit.assertEquals(transform.scaleX, 1)
|
||||
luaunit.assertEquals(transform.scaleY, 1)
|
||||
luaunit.assertEquals(transform.translateX, 0)
|
||||
luaunit.assertEquals(transform.translateY, 0)
|
||||
luaunit.assertEquals(transform.skewX, 0)
|
||||
luaunit.assertEquals(transform.skewY, 0)
|
||||
luaunit.assertEquals(transform.originX, 0.5)
|
||||
luaunit.assertEquals(transform.originY, 0.5)
|
||||
end
|
||||
|
||||
function TestTransform:testNew_CustomValues()
|
||||
local transform = Transform.new({
|
||||
rotate = math.pi / 4,
|
||||
scaleX = 2,
|
||||
scaleY = 3,
|
||||
translateX = 100,
|
||||
translateY = 200,
|
||||
skewX = 0.1,
|
||||
skewY = 0.2,
|
||||
originX = 0,
|
||||
originY = 1,
|
||||
})
|
||||
|
||||
luaunit.assertAlmostEquals(transform.rotate, math.pi / 4, 0.01)
|
||||
luaunit.assertEquals(transform.scaleX, 2)
|
||||
luaunit.assertEquals(transform.scaleY, 3)
|
||||
luaunit.assertEquals(transform.translateX, 100)
|
||||
luaunit.assertEquals(transform.translateY, 200)
|
||||
luaunit.assertAlmostEquals(transform.skewX, 0.1, 0.01)
|
||||
luaunit.assertAlmostEquals(transform.skewY, 0.2, 0.01)
|
||||
luaunit.assertEquals(transform.originX, 0)
|
||||
luaunit.assertEquals(transform.originY, 1)
|
||||
end
|
||||
|
||||
function TestTransform:testNew_PartialValues()
|
||||
local transform = Transform.new({
|
||||
rotate = math.pi,
|
||||
scaleX = 2,
|
||||
})
|
||||
|
||||
luaunit.assertAlmostEquals(transform.rotate, math.pi, 0.01)
|
||||
luaunit.assertEquals(transform.scaleX, 2)
|
||||
luaunit.assertEquals(transform.scaleY, 1) -- default
|
||||
luaunit.assertEquals(transform.translateX, 0) -- default
|
||||
end
|
||||
|
||||
function TestTransform:testNew_EmptyProps()
|
||||
local transform = Transform.new({})
|
||||
|
||||
-- Should use all defaults
|
||||
luaunit.assertEquals(transform.rotate, 0)
|
||||
luaunit.assertEquals(transform.scaleX, 1)
|
||||
luaunit.assertEquals(transform.originX, 0.5)
|
||||
end
|
||||
|
||||
function TestTransform:testNew_NilProps()
|
||||
local transform = Transform.new(nil)
|
||||
|
||||
-- Should use all defaults
|
||||
luaunit.assertEquals(transform.rotate, 0)
|
||||
luaunit.assertEquals(transform.scaleX, 1)
|
||||
end
|
||||
|
||||
-- Test Transform.lerp()
|
||||
|
||||
function TestTransform:testLerp_MidPoint()
|
||||
local from = Transform.new({ rotate = 0, scaleX = 1, scaleY = 1 })
|
||||
local to = Transform.new({ rotate = math.pi, scaleX = 2, scaleY = 3 })
|
||||
|
||||
local result = Transform.lerp(from, to, 0.5)
|
||||
|
||||
luaunit.assertAlmostEquals(result.rotate, math.pi / 2, 0.01)
|
||||
luaunit.assertAlmostEquals(result.scaleX, 1.5, 0.01)
|
||||
luaunit.assertAlmostEquals(result.scaleY, 2, 0.01)
|
||||
end
|
||||
|
||||
function TestTransform:testLerp_StartPoint()
|
||||
local from = Transform.new({ rotate = 0, scaleX = 1 })
|
||||
local to = Transform.new({ rotate = math.pi, scaleX = 2 })
|
||||
|
||||
local result = Transform.lerp(from, to, 0)
|
||||
|
||||
luaunit.assertAlmostEquals(result.rotate, 0, 0.01)
|
||||
luaunit.assertAlmostEquals(result.scaleX, 1, 0.01)
|
||||
end
|
||||
|
||||
function TestTransform:testLerp_EndPoint()
|
||||
local from = Transform.new({ rotate = 0, scaleX = 1 })
|
||||
local to = Transform.new({ rotate = math.pi, scaleX = 2 })
|
||||
|
||||
local result = Transform.lerp(from, to, 1)
|
||||
|
||||
luaunit.assertAlmostEquals(result.rotate, math.pi, 0.01)
|
||||
luaunit.assertAlmostEquals(result.scaleX, 2, 0.01)
|
||||
end
|
||||
|
||||
function TestTransform:testLerp_AllProperties()
|
||||
local from = Transform.new({
|
||||
rotate = 0,
|
||||
scaleX = 1,
|
||||
scaleY = 1,
|
||||
translateX = 0,
|
||||
translateY = 0,
|
||||
skewX = 0,
|
||||
skewY = 0,
|
||||
originX = 0,
|
||||
originY = 0,
|
||||
})
|
||||
|
||||
local to = Transform.new({
|
||||
rotate = math.pi,
|
||||
scaleX = 2,
|
||||
scaleY = 3,
|
||||
translateX = 100,
|
||||
translateY = 200,
|
||||
skewX = 0.2,
|
||||
skewY = 0.4,
|
||||
originX = 1,
|
||||
originY = 1,
|
||||
})
|
||||
|
||||
local result = Transform.lerp(from, to, 0.5)
|
||||
|
||||
luaunit.assertAlmostEquals(result.rotate, math.pi / 2, 0.01)
|
||||
luaunit.assertAlmostEquals(result.scaleX, 1.5, 0.01)
|
||||
luaunit.assertAlmostEquals(result.scaleY, 2, 0.01)
|
||||
luaunit.assertAlmostEquals(result.translateX, 50, 0.01)
|
||||
luaunit.assertAlmostEquals(result.translateY, 100, 0.01)
|
||||
luaunit.assertAlmostEquals(result.skewX, 0.1, 0.01)
|
||||
luaunit.assertAlmostEquals(result.skewY, 0.2, 0.01)
|
||||
luaunit.assertAlmostEquals(result.originX, 0.5, 0.01)
|
||||
luaunit.assertAlmostEquals(result.originY, 0.5, 0.01)
|
||||
end
|
||||
|
||||
function TestTransform:testLerp_InvalidInputs()
|
||||
-- Should handle nil gracefully
|
||||
local result = Transform.lerp(nil, nil, 0.5)
|
||||
|
||||
luaunit.assertNotNil(result)
|
||||
luaunit.assertEquals(result.rotate, 0)
|
||||
luaunit.assertEquals(result.scaleX, 1)
|
||||
end
|
||||
|
||||
function TestTransform:testLerp_ClampT()
|
||||
local from = Transform.new({ scaleX = 1 })
|
||||
local to = Transform.new({ scaleX = 2 })
|
||||
|
||||
-- Test t > 1
|
||||
local result1 = Transform.lerp(from, to, 1.5)
|
||||
luaunit.assertAlmostEquals(result1.scaleX, 2, 0.01)
|
||||
|
||||
-- Test t < 0
|
||||
local result2 = Transform.lerp(from, to, -0.5)
|
||||
luaunit.assertAlmostEquals(result2.scaleX, 1, 0.01)
|
||||
end
|
||||
|
||||
function TestTransform:testLerp_InvalidT()
|
||||
local from = Transform.new({ scaleX = 1 })
|
||||
local to = Transform.new({ scaleX = 2 })
|
||||
|
||||
-- Test NaN
|
||||
local result1 = Transform.lerp(from, to, 0 / 0)
|
||||
luaunit.assertAlmostEquals(result1.scaleX, 1, 0.01) -- Should default to 0
|
||||
|
||||
-- Test Infinity
|
||||
local result2 = Transform.lerp(from, to, math.huge)
|
||||
luaunit.assertAlmostEquals(result2.scaleX, 2, 0.01) -- Should clamp to 1
|
||||
end
|
||||
|
||||
-- Test Transform.isIdentity()
|
||||
|
||||
function TestTransform:testIsIdentity_True()
|
||||
local transform = Transform.new()
|
||||
luaunit.assertTrue(Transform.isIdentity(transform))
|
||||
end
|
||||
|
||||
function TestTransform:testIsIdentity_Nil()
|
||||
luaunit.assertTrue(Transform.isIdentity(nil))
|
||||
end
|
||||
|
||||
function TestTransform:testIsIdentity_FalseRotate()
|
||||
local transform = Transform.new({ rotate = 0.1 })
|
||||
luaunit.assertFalse(Transform.isIdentity(transform))
|
||||
end
|
||||
|
||||
function TestTransform:testIsIdentity_FalseScale()
|
||||
local transform = Transform.new({ scaleX = 2 })
|
||||
luaunit.assertFalse(Transform.isIdentity(transform))
|
||||
end
|
||||
|
||||
function TestTransform:testIsIdentity_FalseTranslate()
|
||||
local transform = Transform.new({ translateX = 10 })
|
||||
luaunit.assertFalse(Transform.isIdentity(transform))
|
||||
end
|
||||
|
||||
function TestTransform:testIsIdentity_FalseSkew()
|
||||
local transform = Transform.new({ skewX = 0.1 })
|
||||
luaunit.assertFalse(Transform.isIdentity(transform))
|
||||
end
|
||||
|
||||
-- Test Transform.clone()
|
||||
|
||||
function TestTransform:testClone_AllProperties()
|
||||
local original = Transform.new({
|
||||
rotate = math.pi / 4,
|
||||
scaleX = 2,
|
||||
scaleY = 3,
|
||||
translateX = 100,
|
||||
translateY = 200,
|
||||
skewX = 0.1,
|
||||
skewY = 0.2,
|
||||
originX = 0.25,
|
||||
originY = 0.75,
|
||||
})
|
||||
|
||||
local clone = Transform.clone(original)
|
||||
|
||||
luaunit.assertAlmostEquals(clone.rotate, math.pi / 4, 0.01)
|
||||
luaunit.assertEquals(clone.scaleX, 2)
|
||||
luaunit.assertEquals(clone.scaleY, 3)
|
||||
luaunit.assertEquals(clone.translateX, 100)
|
||||
luaunit.assertEquals(clone.translateY, 200)
|
||||
luaunit.assertAlmostEquals(clone.skewX, 0.1, 0.01)
|
||||
luaunit.assertAlmostEquals(clone.skewY, 0.2, 0.01)
|
||||
luaunit.assertAlmostEquals(clone.originX, 0.25, 0.01)
|
||||
luaunit.assertAlmostEquals(clone.originY, 0.75, 0.01)
|
||||
|
||||
-- Ensure it's a different object
|
||||
luaunit.assertNotEquals(clone, original)
|
||||
end
|
||||
|
||||
function TestTransform:testClone_Nil()
|
||||
local clone = Transform.clone(nil)
|
||||
|
||||
luaunit.assertNotNil(clone)
|
||||
luaunit.assertEquals(clone.rotate, 0)
|
||||
luaunit.assertEquals(clone.scaleX, 1)
|
||||
end
|
||||
|
||||
function TestTransform:testClone_Mutation()
|
||||
local original = Transform.new({ rotate = 0 })
|
||||
local clone = Transform.clone(original)
|
||||
|
||||
-- Mutate clone
|
||||
clone.rotate = math.pi
|
||||
|
||||
-- Original should be unchanged
|
||||
luaunit.assertEquals(original.rotate, 0)
|
||||
luaunit.assertAlmostEquals(clone.rotate, math.pi, 0.01)
|
||||
end
|
||||
|
||||
-- Integration Tests
|
||||
|
||||
function TestTransform:testTransformAnimation()
|
||||
local Animation = require("modules.Animation")
|
||||
local Transform = require("modules.Transform")
|
||||
|
||||
local anim = Animation.new({
|
||||
duration = 1,
|
||||
start = { transform = Transform.new({ rotate = 0, scaleX = 1 }) },
|
||||
final = { transform = Transform.new({ rotate = math.pi, scaleX = 2 }) },
|
||||
})
|
||||
|
||||
anim:setTransformModule(Transform)
|
||||
anim:update(0.5)
|
||||
|
||||
local result = anim:interpolate()
|
||||
|
||||
luaunit.assertNotNil(result.transform)
|
||||
luaunit.assertAlmostEquals(result.transform.rotate, math.pi / 2, 0.01)
|
||||
luaunit.assertAlmostEquals(result.transform.scaleX, 1.5, 0.01)
|
||||
end
|
||||
|
||||
os.exit(luaunit.LuaUnit.run())
|
||||
@@ -18,6 +18,8 @@ local luaunit = require("testing.luaunit")
|
||||
-- Run all tests in the __tests__ directory
|
||||
local testFiles = {
|
||||
"testing/__tests__/animation_test.lua",
|
||||
"testing/__tests__/animation_properties_test.lua",
|
||||
"testing/__tests__/transform_test.lua",
|
||||
"testing/__tests__/blur_test.lua",
|
||||
"testing/__tests__/color_validation_test.lua",
|
||||
"testing/__tests__/element_test.lua",
|
||||
|
||||
Reference in New Issue
Block a user