From 32eda9ff8ba27c649de63faff958866a9e6c120b Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Wed, 19 Nov 2025 14:10:18 -0500 Subject: [PATCH] continued refactor --- modules/Animation.lua | 841 +++++++++++++----------------- modules/Blur.lua | 353 +++++++------ modules/Color.lua | 156 +----- modules/Context.lua | 21 +- modules/EventHandler.lua | 6 +- modules/Performance.lua | 734 ++++++++------------------ testing/__tests__/easing_test.lua | 29 -- 7 files changed, 830 insertions(+), 1310 deletions(-) diff --git a/modules/Animation.lua b/modules/Animation.lua index 40df83d..29fad5a 100644 --- a/modules/Animation.lua +++ b/modules/Animation.lua @@ -1,19 +1,16 @@ +---@alias EasingFunction fun(t: number): number + -- ============================================================================ -- EASING FUNCTIONS -- ============================================================================ ---- Easing function type ----@alias EasingFunction fun(t: number): number - local Easing = {} --- Linear ---@type EasingFunction function Easing.linear(t) return t end --- Quadratic (Quad) ---@type EasingFunction function Easing.easeInQuad(t) return t * t @@ -29,7 +26,6 @@ function Easing.easeInOutQuad(t) return t < 0.5 and 2 * t * t or -1 + (4 - 2 * t) * t end --- Cubic ---@type EasingFunction function Easing.easeInCubic(t) return t * t * t @@ -46,7 +42,6 @@ function Easing.easeInOutCubic(t) return t < 0.5 and 4 * t * t * t or (t - 1) * (2 * t - 2) * (2 * t - 2) + 1 end --- Quartic (Quart) ---@type EasingFunction function Easing.easeInQuart(t) return t * t * t * t @@ -68,7 +63,6 @@ function Easing.easeInOutQuart(t) end end --- Quintic (Quint) ---@type EasingFunction function Easing.easeInQuint(t) return t * t * t * t * t @@ -90,7 +84,6 @@ function Easing.easeInOutQuint(t) end end --- Exponential (Expo) ---@type EasingFunction function Easing.easeInExpo(t) return t == 0 and 0 or math.pow(2, 10 * (t - 1)) @@ -117,7 +110,6 @@ function Easing.easeInOutExpo(t) end end --- Sine ---@type EasingFunction function Easing.easeInSine(t) return 1 - math.cos(t * math.pi / 2) @@ -133,7 +125,6 @@ function Easing.easeInOutSine(t) return -(math.cos(math.pi * t) - 1) / 2 end --- Circular (Circ) ---@type EasingFunction function Easing.easeInCirc(t) return 1 - math.sqrt(1 - t * t) @@ -155,7 +146,6 @@ function Easing.easeInOutCirc(t) end end --- Back (Overshoot) ---@type EasingFunction function Easing.easeInBack(t) local c1 = 1.70158 @@ -184,7 +174,6 @@ function Easing.easeInOutBack(t) end end --- Elastic (Spring) ---@type EasingFunction function Easing.easeInElastic(t) if t == 0 then @@ -229,7 +218,6 @@ function Easing.easeInOutElastic(t) end end --- Bounce ---@type EasingFunction function Easing.easeOutBounce(t) local n1 = 7.5625 @@ -263,7 +251,6 @@ function Easing.easeInOutBounce(t) end end --- Configurable Easing Factories --- Create a custom back easing function with configurable overshoot ---@param overshoot number? Overshoot amount (default: 1.70158) ---@return EasingFunction @@ -306,51 +293,6 @@ function Easing.elastic(amplitude, period) end end ---- Get list of all available easing function names ----@return string[] names Array of easing function names -function Easing.list() - return { - "linear", - "easeInQuad", - "easeOutQuad", - "easeInOutQuad", - "easeInCubic", - "easeOutCubic", - "easeInOutCubic", - "easeInQuart", - "easeOutQuart", - "easeInOutQuart", - "easeInQuint", - "easeOutQuint", - "easeInOutQuint", - "easeInExpo", - "easeOutExpo", - "easeInOutExpo", - "easeInSine", - "easeOutSine", - "easeInOutSine", - "easeInCirc", - "easeOutCirc", - "easeInOutCirc", - "easeInBack", - "easeOutBack", - "easeInOutBack", - "easeInElastic", - "easeOutElastic", - "easeInOutElastic", - "easeInBounce", - "easeOutBounce", - "easeInOutBounce", - } -end - ---- Get an easing function by name ----@param name string Easing function name ----@return EasingFunction? easing The easing function, or nil if not found -function Easing.get(name) - return Easing[name] -end - -- ============================================================================ -- TRANSFORM -- ============================================================================ @@ -369,7 +311,7 @@ local Transform = {} Transform.__index = Transform --- Create a new transform instance ----@param props TransformProps? +---@param props Transform? ---@return Transform transform function Transform.new(props) props = props or {} @@ -400,11 +342,9 @@ function Transform.apply(transform, x, y, width, height) 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) @@ -435,7 +375,6 @@ end ---@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 @@ -504,212 +443,9 @@ function Transform.clone(transform) end -- ============================================================================ --- ANIMATION +-- INTERPOLATION HELPERS -- ============================================================================ ----@class Keyframe ----@field at number Normalized time position (0-1) ----@field values table Property values at this keyframe ----@field easing string|EasingFunction? Easing to use between this and next keyframe - ----@class AnimationProps ----@field duration number Duration in seconds ----@field start table Starting values (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 keyframes Keyframe[]? Array of keyframes for complex animations ----@field transform table? Additional transform properties ----@field transition table? Transition properties ----@field onStart function? Called when animation starts: (animation, element) ----@field onUpdate function? Called each frame: (animation, element, progress) ----@field onComplete function? Called when animation completes: (animation, element) ----@field onCancel function? Called when animation is cancelled: (animation, element) - ----@class Animation ----@field duration number Duration in seconds ----@field start table Starting values ----@field final table Final values ----@field elapsed number Elapsed time in seconds ----@field easing EasingFunction Easing function ----@field keyframes Keyframe[]? Array of keyframes for complex animations ----@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 - ---- Build smooth, timed transitions between visual states to create polished, professional UIs ---- Use this to animate position, size, opacity, colors, and other properties with customizable easing ----@param props AnimationProps Animation properties ----@return Animation animation The new animation instance -function Animation.new(props) - -- Validate input - if type(props) ~= "table" then - Animation._ErrorHandler.warn("Animation", "Animation.new() requires a table argument. Using default values.") - props = { duration = 1, start = {}, final = {} } - end - - if type(props.duration) ~= "number" or props.duration <= 0 then - Animation._ErrorHandler.warn("Animation", "Animation duration must be a positive number. Using 1 second.") - props.duration = 1 - end - - if type(props.start) ~= "table" then - Animation._ErrorHandler.warn("Animation", "Animation start must be a table. Using empty table.") - props.start = {} - end - - if type(props.final) ~= "table" then - Animation._ErrorHandler.warn("Animation", "Animation final must be a table. Using empty table.") - props.final = {} - end - - local self = setmetatable({}, Animation) - self.duration = props.duration - self.start = props.start - self.final = props.final - self.keyframes = props.keyframes - self.transform = props.transform - self.transition = props.transition - self.elapsed = 0 - - -- 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" - - -- Validate and set easing function - local easingName = props.easing or "linear" - if type(easingName) == "string" then - self.easing = Easing[easingName] or Easing.linear - elseif type(easingName) == "function" then - self.easing = easingName - else - self.easing = Easing.linear - end - - -- Pre-allocate result table to avoid GC pressure - self._cachedResult = {} - self._resultDirty = true - - return self -end - ---- Advance the animation timeline and calculate interpolated values for the current frame ---- Call this each frame to progress the animation; returns true when complete for cleanup ----@param dt number Delta time in seconds ----@param element table? Optional element reference for callbacks ----@return boolean completed True if animation is complete -function Animation:update(dt, element) - -- Sanitize dt - if type(dt) ~= "number" or dt < 0 or dt ~= dt or dt == math.huge then - dt = 0 - end - - -- Don't update if paused - if self._paused then - return false - end - - -- Handle delay - if self._delay and self._delayElapsed then - if self._delayElapsed < self._delay then - self._delayElapsed = self._delayElapsed + dt - return false - end - end - - -- Call onStart on first update - if not self._hasStarted then - self._hasStarted = true - self._state = "playing" - if self.onStart and type(self.onStart) == "function" then - local success, err = pcall(self.onStart, self, element) - if not success then - print(string.format("[Animation] onStart error: %s", tostring(err))) - end - end - end - - -- 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 - if self.onComplete and type(self.onComplete) == "function" then - local success, err = pcall(self.onComplete, self, element) - if not success then - print(string.format("[Animation] onComplete error: %s", tostring(err))) - end - end - return true - end - else - self.elapsed = self.elapsed + dt - if self.elapsed >= self.duration then - self.elapsed = self.duration - self._resultDirty = true - - -- Handle repeat and yoyo - if self._repeatCount then - self._repeatCurrent = (self._repeatCurrent or 0) + 1 - - if self._repeatCount == 0 or self._repeatCurrent < self._repeatCount then - if self._yoyo then - self._reversed = not self._reversed - if self._reversed then - self.elapsed = self.duration - else - self.elapsed = 0 - end - else - self.elapsed = 0 - end - return false - end - end - - -- Animation truly completed - self._state = "completed" - if self.onComplete and type(self.onComplete) == "function" then - local success, err = pcall(self.onComplete, self, element) - if not success then - print(string.format("[Animation] onComplete error: %s", tostring(err))) - end - end - return true - end - end - - self._resultDirty = true - - -- 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 @@ -726,19 +462,14 @@ end ---@param ColorModule table Color module reference ---@return any interpolated Interpolated Color instance local function lerpColor(startColor, finalColor, easedT, ColorModule) - local CM = ColorModule or Color or Animation._ColorModule - - if not CM or not CM.parse or not CM.lerp then - if Animation._ErrorHandler then - Animation._ErrorHandler.warn("Animation", "Color module not properly initialized. Cannot interpolate colors.") - end + if not ColorModule or not ColorModule.parse or not ColorModule.lerp then return startColor end - local colorA = CM.parse(startColor) - local colorB = CM.parse(finalColor) + local colorA = ColorModule.parse(startColor) + local colorB = ColorModule.parse(finalColor) - return CM.lerp(colorA, colorB, easedT) + return ColorModule.lerp(colorA, colorB, easedT) end --- Helper function to interpolate table values (padding, margin, cornerRadius) @@ -773,10 +504,209 @@ local function lerpTable(startTable, finalTable, easedT) return result end +---@class Keyframe +---@field at number Normalized time position (0-1) +---@field values table Property values at this keyframe +---@field easing string|EasingFunction? Easing to use between this and next keyframe + +---@class AnimationProps +---@field duration number Duration in seconds +---@field start table Starting values +---@field final table Final values +---@field easing string? Easing function name (default: "linear") +---@field keyframes Keyframe[]? Array of keyframes for complex animations +---@field transform table? Additional transform properties +---@field transition table? Transition properties +---@field onStart function? Called when animation starts: (animation, element) +---@field onUpdate function? Called each frame: (animation, element, progress) +---@field onComplete function? Called when animation completes: (animation, element) +---@field onCancel function? Called when animation is cancelled: (animation, element) + +---@class Animation +---@field duration number Duration in seconds +---@field start table Starting values +---@field final table Final values +---@field elapsed number Elapsed time in seconds +---@field easing EasingFunction Easing function +---@field keyframes Keyframe[]? Array of keyframes +---@field transform table? Additional transform properties +---@field transition table? Transition properties +---@field _cachedResult table Cached interpolation result +---@field _resultDirty boolean Whether cached result needs recalculation +---@field _Color table? Reference to Color module +---@field _Transform table? Reference to Transform module +---@field _ErrorHandler table? Reference to ErrorHandler module +local Animation = { + _Transform = Transform, +} +Animation.__index = Animation + +--- Build smooth, timed transitions between visual states +---@param props AnimationProps Animation properties +---@return Animation animation The new animation instance +function Animation.new(props) + if type(props) ~= "table" then + if Animation._ErrorHandler then + Animation._ErrorHandler:warn("Animation", "Animation.new() requires a table argument. Using default values.") + end + props = { duration = 1, start = {}, final = {} } + end + + if type(props.duration) ~= "number" or props.duration <= 0 then + if Animation._ErrorHandler then + Animation._ErrorHandler:warn("Animation", "Animation duration must be a positive number. Using 1 second.") + end + props.duration = 1 + end + + if type(props.start) ~= "table" then + if Animation._ErrorHandler then + Animation._ErrorHandler:warn("Animation", "Animation start must be a table. Using empty table.") + end + props.start = {} + end + + if type(props.final) ~= "table" then + if Animation._ErrorHandler then + Animation._ErrorHandler:warn("Animation", "Animation final must be a table. Using empty table.") + end + props.final = {} + end + + local self = setmetatable({}, Animation) + self.duration = props.duration + self.start = props.start + self.final = props.final + self.keyframes = props.keyframes + self.transform = props.transform + self.transition = props.transition + self.elapsed = 0 + + self.onStart = props.onStart + self.onUpdate = props.onUpdate + self.onComplete = props.onComplete + self.onCancel = props.onCancel + self._hasStarted = false + + self._paused = false + self._reversed = false + self._speed = 1.0 + self._state = "pending" + + local easingName = props.easing or "linear" + if type(easingName) == "string" then + self.easing = Easing[easingName] or Easing.linear + elseif type(easingName) == "function" then + self.easing = easingName + else + self.easing = Easing.linear + end + + self._cachedResult = {} + self._resultDirty = true + + return self +end + +--- Advance the animation timeline +---@param dt number Delta time in seconds +---@param element table? Optional element reference for callbacks +---@return boolean completed True if animation is complete +function Animation:update(dt, element) + if type(dt) ~= "number" or dt < 0 or dt ~= dt or dt == math.huge then + dt = 0 + end + + if self._paused then + return false + end + + if self._delay and self._delayElapsed then + if self._delayElapsed < self._delay then + self._delayElapsed = self._delayElapsed + dt + return false + end + end + + if not self._hasStarted then + self._hasStarted = true + self._state = "playing" + if self.onStart and type(self.onStart) == "function" then + local success, err = pcall(self.onStart, self, element) + if not success then + print(string.format("[Animation] onStart error: %s", tostring(err))) + end + end + end + + dt = dt * self._speed + + if self._reversed then + self.elapsed = self.elapsed - dt + if self.elapsed <= 0 then + self.elapsed = 0 + self._state = "completed" + self._resultDirty = true + if self.onComplete and type(self.onComplete) == "function" then + local success, err = pcall(self.onComplete, self, element) + if not success then + print(string.format("[Animation] onComplete error: %s", tostring(err))) + end + end + return true + end + else + self.elapsed = self.elapsed + dt + if self.elapsed >= self.duration then + self.elapsed = self.duration + self._resultDirty = true + + if self._repeatCount then + self._repeatCurrent = (self._repeatCurrent or 0) + 1 + + if self._repeatCount == 0 or self._repeatCurrent < self._repeatCount then + if self._yoyo then + self._reversed = not self._reversed + if self._reversed then + self.elapsed = self.duration + else + self.elapsed = 0 + end + else + self.elapsed = 0 + end + return false + end + end + + self._state = "completed" + if self.onComplete and type(self.onComplete) == "function" then + local success, err = pcall(self.onComplete, self, element) + if not success then + print(string.format("[Animation] onComplete error: %s", tostring(err))) + end + end + return true + end + end + + self._resultDirty = true + + if self.onUpdate and type(self.onUpdate) == "function" then + local progress = self.elapsed / self.duration + local success, err = pcall(self.onUpdate, self, element, progress) + if not success then + print(string.format("[Animation] onUpdate error: %s", tostring(err))) + end + end + + return false +end + --- Find the two keyframes surrounding the current progress ---@param progress number Current animation progress (0-1) ----@return Keyframe prevFrame The keyframe before current progress ----@return Keyframe nextFrame The keyframe after current progress +---@return Keyframe? prevFrame The keyframe before current progress +---@return Keyframe? nextFrame The keyframe after current progress function Animation:findKeyframes(progress) if not self.keyframes or #self.keyframes < 2 then return nil, nil @@ -812,59 +742,44 @@ function Animation:lerpKeyframes(prevFrame, nextFrame, easedT) keys[k] = true end - local numericProperties = { - "width", - "height", - "opacity", - "x", - "y", - "gap", - "imageOpacity", - "scrollbarWidth", - "borderWidth", - "fontSize", - "lineHeight", + local numericSet = { + width = true, + height = true, + opacity = true, + x = true, + y = true, + gap = true, + imageOpacity = true, + scrollbarWidth = true, + borderWidth = true, + fontSize = true, + lineHeight = true, } - local colorProperties = { - "backgroundColor", - "borderColor", - "textColor", - "scrollbarColor", - "scrollbarBackgroundColor", - "imageTint", + local colorSet = { + backgroundColor = true, + borderColor = true, + textColor = true, + scrollbarColor = true, + scrollbarBackgroundColor = true, + imageTint = true, } - local tableProperties = { - "padding", - "margin", - "cornerRadius", + local tableSet = { + padding = true, + margin = true, + cornerRadius = true, } - local numericSet = {} - for _, prop in ipairs(numericProperties) do - numericSet[prop] = true - end - - local colorSet = {} - for _, prop in ipairs(colorProperties) do - colorSet[prop] = true - end - - local tableSet = {} - for _, prop in ipairs(tableProperties) do - tableSet[prop] = true - end - for key in pairs(keys) do local startVal = prevFrame.values[key] local finalVal = nextFrame.values[key] if numericSet[key] and type(startVal) == "number" and type(finalVal) == "number" then result[key] = lerpNumber(startVal, finalVal, easedT) - elseif colorSet[key] and (self._Color or Animation._ColorModule) then + elseif colorSet[key] and Animation._ColorModule then if startVal ~= nil and finalVal ~= nil then - result[key] = lerpColor(startVal, finalVal, easedT, self._Color or Animation._ColorModule) + result[key] = lerpColor(startVal, finalVal, easedT, Animation._ColorModule) end elseif tableSet[key] and type(startVal) == "table" and type(finalVal) == "table" then result[key] = lerpTable(startVal, finalVal, easedT) @@ -880,18 +795,15 @@ function Animation:lerpKeyframes(prevFrame, nextFrame, easedT) return result end ---- Calculate the current animated values between start and end states based on elapsed time ---- Use this to get the interpolated properties to apply to your element ----@return table result Interpolated values {width?, height?, opacity?, x?, y?, backgroundColor?, ...} +--- Calculate the current animated values +---@return table result Interpolated values function Animation:interpolate() - -- Return cached result if not dirty if not self._resultDirty then return self._cachedResult end local t = math.min(self.elapsed / self.duration, 1) - -- Handle keyframe animations if self.keyframes and type(self.keyframes) == "table" and #self.keyframes >= 2 then local prevFrame, nextFrame = self:findKeyframes(t) @@ -930,7 +842,6 @@ function Animation:interpolate() end end - -- Standard interpolation (non-keyframe) local success, easedT = pcall(self.easing, t) if not success or type(easedT) ~= "number" or easedT ~= easedT or easedT == math.huge or easedT == -math.huge then easedT = t @@ -980,14 +891,13 @@ function Animation:interpolate() end end - local ColorModule = self._Color or Animation._ColorModule - if ColorModule then + if Animation._ColorModule then for _, prop in ipairs(colorProperties) do local startVal = self.start[prop] local finalVal = self.final[prop] if startVal ~= nil and finalVal ~= nil then - result[prop] = lerpColor(startVal, finalVal, easedT, ColorModule) + result[prop] = lerpColor(startVal, finalVal, easedT, Animation._ColorModule) end end end @@ -1001,8 +911,8 @@ function Animation:interpolate() end end - if self._Transform and self.start.transform and self.final.transform then - result.transform = self._Transform.lerp(self.start.transform, self.final.transform, easedT) + if Animation._Transform and self.start.transform and self.final.transform then + result.transform = Animation._Transform.lerp(self.start.transform, self.final.transform, easedT) end if self.transform and type(self.transform) == "table" then @@ -1015,19 +925,19 @@ function Animation:interpolate() return result end ---- Attach this animation to an element so it automatically updates and applies changes ---- Use this for hands-off animation that integrates with FlexLove's rendering system ----@param element Element The element to apply animation to +--- Attach animation to an element +---@param element table The element to apply animation to function Animation:apply(element) if not element or type(element) ~= "table" then - Animation._ErrorHandler.warn("Animation", "Cannot apply animation to nil or non-table element. Animation not applied.") + if Animation._ErrorHandler then + Animation._ErrorHandler:warn("Animation", "Cannot apply animation to nil or non-table element.") + end return end element.animation = self end ---- Temporarily halt the animation without losing progress ---- Use this to freeze animations during pause menus or cutscenes +--- Pause animation function Animation:pause() if self._state == "playing" or self._state == "pending" then self._paused = true @@ -1035,8 +945,7 @@ function Animation:pause() end end ---- Continue a paused animation from where it left off ---- Use this to unpause animations when returning from pause menus +--- Resume animation function Animation:resume() if self._state == "paused" then self._paused = false @@ -1044,45 +953,39 @@ function Animation:resume() end end ---- Query pause state to conditionally handle animation logic ---- Use this to sync UI behavior with animation state +--- Check if paused ---@return boolean paused function Animation:isPaused() return self._paused end ---- Flip the animation to play backwards, creating smooth transitions in both directions ---- Use this for hover effects that reverse on mouse-out or toggleable UI elements +--- Reverse animation direction function Animation:reverse() self._reversed = not self._reversed end ---- Determine current playback direction for conditional animation logic ---- Use this to track which direction the animation is playing +--- Check if reversed ---@return boolean reversed function Animation:isReversed() return self._reversed end ---- Control animation tempo for slow-motion or fast-forward effects ---- Use this for bullet-time, game speed multipliers, or debugging ----@param speed number Speed multiplier (1.0 = normal, 2.0 = double speed, 0.5 = half speed) +--- Set playback speed +---@param speed number Speed multiplier function Animation:setSpeed(speed) if type(speed) == "number" and speed > 0 then self._speed = speed end end ---- Check current playback speed for debugging or UI display ---- Use this to show animation speed in dev tools ----@return number speed Current speed multiplier +--- Get playback speed +---@return number speed function Animation:getSpeed() return self._speed end ---- Jump to any point in the animation timeline for previewing or state restoration ---- Use this to skip ahead, rewind, or restore saved animation states ----@param time number Time in seconds (clamped to 0-duration) +--- Seek to specific time +---@param time number Time in seconds function Animation:seek(time) if type(time) == "number" then self.elapsed = math.max(0, math.min(time, self.duration)) @@ -1090,16 +993,14 @@ function Animation:seek(time) end end ---- Query animation lifecycle state for conditional logic and debugging ---- Use this to determine if cleanup is needed or to prevent duplicate animations ----@return string state Current state: "pending", "playing", "paused", "completed", "cancelled" +--- Get animation state +---@return string state function Animation:getState() return self._state end ---- Stop the animation immediately without completing, triggering the onCancel callback ---- Use this to abort animations when UI elements are removed or user cancels an action ----@param element table? Optional element reference for callback +--- Cancel animation +---@param element table? Optional element reference function Animation:cancel(element) if self._state ~= "cancelled" and self._state ~= "completed" then self._state = "cancelled" @@ -1112,8 +1013,7 @@ function Animation:cancel(element) end end ---- Return the animation to the beginning for replay ---- Use this to reuse animation instances without recreating them +--- Reset animation function Animation:reset() self.elapsed = 0 self._hasStarted = false @@ -1122,17 +1022,15 @@ function Animation:reset() self._resultDirty = true end ---- Get normalized animation progress for progress bars or synchronized effects ---- Use this to drive secondary animations or display completion percentage ----@return number progress Progress from 0 to 1 +--- Get animation progress +---@return number progress function Animation:getProgress() return math.min(self.elapsed / self.duration, 1) end ---- Create sequential animation flows that play one after another ---- Use this to build complex multi-step animations like slide-in-then-fade ----@param nextAnimation Animation|function Animation instance or factory function that returns an animation ----@return Animation nextAnimation The chained animation (for further chaining) +--- Chain animations +---@param nextAnimation Animation|function +---@return Animation nextAnimation function Animation:chain(nextAnimation) if type(nextAnimation) == "function" then self._nextFactory = nextAnimation @@ -1141,18 +1039,21 @@ function Animation:chain(nextAnimation) self._next = nextAnimation return nextAnimation else - Animation._ErrorHandler.warn("Animation", "chain() requires an Animation or function. Chaining not applied.") + if Animation._ErrorHandler then + Animation._ErrorHandler:warn("Animation", "chain() requires an Animation or function.") + end return self end end ---- Introduce a wait period before animation begins for staggered effects ---- Use this to create cascading animations or timed sequences ----@param seconds number Delay duration in seconds ----@return Animation self For chaining +--- Add delay before animation starts +---@param seconds number Delay duration +---@return Animation self function Animation:delay(seconds) if type(seconds) ~= "number" or seconds < 0 then - Animation._ErrorHandler.warn("Animation", "delay() requires a non-negative number. Using 0.") + if Animation._ErrorHandler then + Animation._ErrorHandler:warn("Animation", "delay() requires a non-negative number. Using 0.") + end seconds = 0 end self._delay = seconds @@ -1160,13 +1061,14 @@ function Animation:delay(seconds) return self end ---- Loop the animation for pulsing effects, loading indicators, or continuous motion ---- Use this for idle animations and attention-grabbing elements ----@param count number Number of times to repeat (0 = infinite loop) ----@return Animation self For chaining +--- Set repeat count +---@param count number Repeat count (0 = infinite) +---@return Animation self function Animation:repeatCount(count) if type(count) ~= "number" or count < 0 then - Animation._ErrorHandler.warn("Animation", "repeatCount() requires a non-negative number. Using 0.") + if Animation._ErrorHandler then + Animation._ErrorHandler:warn("Animation", "repeatCount() requires a non-negative number. Using 0.") + end count = 0 end self._repeatCount = count @@ -1174,10 +1076,9 @@ function Animation:repeatCount(count) return self end ---- Make repeating animations play forwards then backwards for smooth oscillation ---- Use this for breathing effects, pulsing highlights, or pendulum motions ----@param enabled boolean? Enable yoyo mode (default: true) ----@return Animation self For chaining +--- Enable yoyo mode +---@param enabled boolean? Enable yoyo (default: true) +---@return Animation self function Animation:yoyo(enabled) if enabled == nil then enabled = true @@ -1186,13 +1087,12 @@ function Animation:yoyo(enabled) return self end ---- Quickly create fade in/out effects without manually specifying start/end states ---- Use this convenience method for common opacity transitions in tooltips, notifications, and overlays +--- Create fade animation ---@param duration number Duration in seconds ----@param fromOpacity number Starting opacity (0-1) ----@param toOpacity number Ending opacity (0-1) ----@param easing string? Easing function name (default: "linear") ----@return Animation animation The fade animation +---@param fromOpacity number Starting opacity +---@param toOpacity number Ending opacity +---@param easing string? Easing function name +---@return Animation animation function Animation.fade(duration, fromOpacity, toOpacity, easing) if type(duration) ~= "number" or duration <= 0 then duration = 1 @@ -1209,18 +1109,15 @@ function Animation.fade(duration, fromOpacity, toOpacity, easing) start = { opacity = fromOpacity }, final = { opacity = toOpacity }, easing = easing, - transform = {}, - transition = {}, }) end ---- Quickly create grow/shrink effects without manually specifying dimensions ---- Use this convenience method for bounce effects, pop-ups, and attention animations +--- Create scale animation ---@param duration number Duration in seconds ---@param fromScale {width:number,height:number} Starting scale ---@param toScale {width:number,height:number} Ending scale ----@param easing string? Easing function name (default: "linear") ----@return Animation animation The scale animation +---@param easing string? Easing function name +---@return Animation animation function Animation.scale(duration, fromScale, toScale, easing) if type(duration) ~= "number" or duration <= 0 then duration = 1 @@ -1237,28 +1134,31 @@ function Animation.scale(duration, fromScale, toScale, easing) start = { width = fromScale.width or 0, height = fromScale.height or 0 }, final = { width = toScale.width or 0, height = toScale.height or 0 }, easing = easing, - transform = {}, - transition = {}, }) end ---- Create a keyframe-based animation with multiple waypoints and per-keyframe easing ---- Use this for complex multi-step animations like bounce-in effects or CSS-style @keyframes ----@param props {duration:number, keyframes:Keyframe[], onStart:function?, onUpdate:function?, onComplete:function?, onCancel:function?} Animation properties ----@return Animation animation The keyframe animation +--- Create keyframe animation +---@param props {duration:number, keyframes:Keyframe[], onStart:function?, onUpdate:function?, onComplete:function?, onCancel:function?} +---@return Animation animation function Animation.keyframes(props) if type(props) ~= "table" then - Animation._ErrorHandler.warn("Animation", "Animation.keyframes() requires a table argument. Using default values.") + if Animation._ErrorHandler then + Animation._ErrorHandler:warn("Animation", "Animation.keyframes() requires a table argument. Using defaults.") + end props = { duration = 1, keyframes = {} } end if type(props.duration) ~= "number" or props.duration <= 0 then - Animation._ErrorHandler.warn("Animation", "Keyframe animation duration must be a positive number. Using 1 second.") + if Animation._ErrorHandler then + Animation._ErrorHandler:warn("Animation", "Keyframe duration must be positive. Using 1 second.") + end props.duration = 1 end if type(props.keyframes) ~= "table" or #props.keyframes < 2 then - Animation._ErrorHandler.warn("Animation", "Keyframe animation requires at least 2 keyframes. Using empty animation.") + if Animation._ErrorHandler then + Animation._ErrorHandler:warn("Animation", "Keyframes require at least 2 keyframes. Using empty animation.") + end props.keyframes = { { at = 0, values = {} }, { at = 1, values = {} }, @@ -1297,46 +1197,47 @@ function Animation.keyframes(props) }) end ---- Initialize dependencies ----@param deps table Dependencies: { ErrorHandler = ErrorHandler, Easing = Easing, Color = Color? } -function Animation.init(deps) - if type(deps) == "table" then - Animation._ErrorHandler = deps.ErrorHandler - Animation._Easing = deps.Easing - Animation._ColorModule = deps.Color - Animation._Transform = Transform - end -end - -- ============================================================================ --- ANIMATION GROUP +-- ANIMATION GROUP (Utility) -- ============================================================================ ----@class AnimationGroup -local AnimationGroup = {} -AnimationGroup.__index = AnimationGroup - -local ErrorHandler = nil - ---@class AnimationGroupProps ---@field animations table Array of Animation instances ---@field mode string? "parallel", "sequence", or "stagger" (default: "parallel") ----@field stagger number? Stagger delay in seconds (for stagger mode, default: 0.1) ----@field onComplete function? Called when all animations complete: (group) ----@field onStart function? Called when group starts: (group) +---@field stagger number? Stagger delay in seconds (default: 0.1) +---@field onComplete function? Called when all animations complete +---@field onStart function? Called when group starts ---- Coordinate multiple animations to play together, in sequence, or staggered for complex choreographed effects ---- Use this to synchronize related UI changes like simultaneous fades or sequential reveals +---@class AnimationGroup +---@field animations table +---@field mode string +---@field stagger number +---@field onComplete function? +---@field onStart function? +---@field _currentIndex number +---@field _staggerElapsed number +---@field _startedAnimations table +---@field _hasStarted boolean +---@field _paused boolean +---@field _state string +local AnimationGroup = {} +AnimationGroup.__index = AnimationGroup + +--- Coordinate multiple animations ---@param props AnimationGroupProps ---@return AnimationGroup group function AnimationGroup.new(props) if type(props) ~= "table" then - ErrorHandler.warn("AnimationGroup", "AnimationGroup.new() requires a table argument. Using default values.") + if Animation._ErrorHandler then + Animation._ErrorHandler:warn("AnimationGroup", "AnimationGroup.new() requires a table. Using defaults.") + end props = { animations = {} } end if type(props.animations) ~= "table" or #props.animations == 0 then - ErrorHandler.warn("AnimationGroup", "AnimationGroup requires at least one animation. Creating empty group.") + if Animation._ErrorHandler then + Animation._ErrorHandler:warn("AnimationGroup", "AnimationGroup requires at least one animation.") + end props.animations = {} end @@ -1349,7 +1250,9 @@ function AnimationGroup.new(props) self.onStart = props.onStart if self.mode ~= "parallel" and self.mode ~= "sequence" and self.mode ~= "stagger" then - ErrorHandler.warn("AnimationGroup", string.format("Invalid mode: %s. Using 'parallel'.", tostring(self.mode))) + if Animation._ErrorHandler then + Animation._ErrorHandler:warn("AnimationGroup", string.format("Invalid mode: %s. Using 'parallel'.", tostring(self.mode))) + end self.mode = "parallel" end @@ -1365,8 +1268,8 @@ end --- Update all animations in parallel ---@param dt number Delta time ----@param element table? Optional element reference for callbacks ----@return boolean finished True if all animations complete +---@param element table? Optional element reference +---@return boolean finished function AnimationGroup:_updateParallel(dt, element) local allFinished = true @@ -1389,10 +1292,10 @@ function AnimationGroup:_updateParallel(dt, element) return allFinished end ---- Update animations in sequence (one after another) +--- Update animations in sequence ---@param dt number Delta time ----@param element table? Optional element reference for callbacks ----@return boolean finished True if all animations complete +---@param element table? Optional element reference +---@return boolean finished function AnimationGroup:_updateSequence(dt, element) if self._currentIndex > #self.animations then return true @@ -1411,10 +1314,10 @@ function AnimationGroup:_updateSequence(dt, element) return false end ---- Update animations with stagger delay +--- Update animations with stagger ---@param dt number Delta time ----@param element table? Optional element reference for callbacks ----@return boolean finished True if all animations complete +---@param element table? Optional element reference +---@return boolean finished function AnimationGroup:_updateStagger(dt, element) self._staggerElapsed = self._staggerElapsed + dt @@ -1450,11 +1353,10 @@ function AnimationGroup:_updateStagger(dt, element) return allFinished end ---- Advance all animations in the group according to their coordination mode ---- Call this each frame to progress parallel, sequential, or staggered animations +--- Advance all animations in the group ---@param dt number Delta time ----@param element table? Optional element reference for callbacks ----@return boolean finished True if group is complete +---@param element table? Optional element reference +---@return boolean finished function AnimationGroup:update(dt, element) if type(dt) ~= "number" or dt < 0 or dt ~= dt or dt == math.huge then dt = 0 @@ -1498,8 +1400,7 @@ function AnimationGroup:update(dt, element) return finished end ---- Freeze the entire animation sequence in unison ---- Use this to pause complex multi-part animations during game pauses +--- Pause all animations function AnimationGroup:pause() self._paused = true for _, anim in ipairs(self.animations) do @@ -1509,8 +1410,7 @@ function AnimationGroup:pause() end end ---- Continue all paused animations simultaneously from their paused states ---- Use this to unpause coordinated animation sequences +--- Resume all animations function AnimationGroup:resume() self._paused = false for _, anim in ipairs(self.animations) do @@ -1520,15 +1420,13 @@ function AnimationGroup:resume() end end ---- Determine if the entire group is currently paused ---- Use this to sync other game logic with animation group state +--- Check if paused ---@return boolean paused function AnimationGroup:isPaused() return self._paused end ---- Flip all animations to play backwards together ---- Use this to reverse complex transitions like panel opens/closes +--- Reverse all animations function AnimationGroup:reverse() for _, anim in ipairs(self.animations) do if type(anim.reverse) == "function" then @@ -1537,8 +1435,7 @@ function AnimationGroup:reverse() end end ---- Control the tempo of all animations simultaneously ---- Use this for slow-motion effects or debugging without adjusting individual animations +--- Set speed for all animations ---@param speed number Speed multiplier function AnimationGroup:setSpeed(speed) for _, anim in ipairs(self.animations) do @@ -1548,9 +1445,8 @@ function AnimationGroup:setSpeed(speed) end end ---- Abort all animations in the group immediately without completion ---- Use this when UI is dismissed mid-animation or transitions are interrupted ----@param element table? Optional element reference for callbacks +--- Cancel all animations +---@param element table? Optional element reference function AnimationGroup:cancel(element) if self._state ~= "cancelled" and self._state ~= "completed" then self._state = "cancelled" @@ -1562,8 +1458,7 @@ function AnimationGroup:cancel(element) end end ---- Restart the entire group from the beginning for reuse ---- Use this to replay animation sequences without recreating objects +--- Reset all animations function AnimationGroup:reset() self._currentIndex = 1 self._staggerElapsed = 0 @@ -1579,15 +1474,13 @@ function AnimationGroup:reset() end end ---- Check the overall lifecycle state of the animation group ---- Use this to conditionally trigger follow-up actions or cleanup ----@return string state "ready", "playing", "completed", "cancelled" +--- Get group state +---@return string state function AnimationGroup:getState() return self._state end ---- Calculate completion percentage across all animations in the group ---- Use this for progress bars or to synchronize other effects with the group +--- Get group progress ---@return number progress function AnimationGroup:getProgress() if #self.animations == 0 then @@ -1619,27 +1512,33 @@ function AnimationGroup:getProgress() end end ---- Attach this group to an element for automatic updates and integration ---- Use this for hands-off animation management within FlexLove's system ----@param element Element The element to apply animations to +--- Apply group to element +---@param element table The element to apply animations to function AnimationGroup:apply(element) if not element or type(element) ~= "table" then - ErrorHandler.warn("AnimationGroup", "Cannot apply animation group to nil or non-table element. Group not applied.") + if Animation._ErrorHandler then + Animation._ErrorHandler:warn("AnimationGroup", "Cannot apply group to nil or non-table element.") + end return end element.animationGroup = self end ---- Initialize dependencies ----@param deps table Dependencies: { ErrorHandler = ErrorHandler } -function AnimationGroup.init(deps) +-- ============================================================================ +-- MODULE INITIALIZATION +-- ============================================================================ + +--- Initialize Animation module with dependencies +---@param deps table Dependencies: { ErrorHandler = ErrorHandler, Color = Color? } +function Animation.init(deps) if type(deps) == "table" then - ErrorHandler = deps.ErrorHandler + Animation._ErrorHandler = deps.ErrorHandler + Animation._ColorModule = deps.Color end end Animation.Easing = Easing Animation.Transform = Transform -Animation.AnimationGroup = AnimationGroup +Animation.Group = AnimationGroup return Animation diff --git a/modules/Blur.lua b/modules/Blur.lua index 2589c5c..d430fbb 100644 --- a/modules/Blur.lua +++ b/modules/Blur.lua @@ -1,99 +1,34 @@ -local Blur = {} - --- Canvas cache to avoid recreating canvases every frame -local canvasCache = {} -local MAX_CACHE_SIZE = 20 - --- Quad cache to avoid recreating quads every frame -local quadCache = {} -local MAX_QUAD_CACHE_SIZE = 20 - --- Quad cache to avoid recreating quads every frame -local quadCache = {} -local MAX_QUAD_CACHE_SIZE = 20 - ---- Build Gaussian blur shader with given parameters ----@param taps number -- Number of samples (must be odd, >= 3) ----@param offset number ----@param offset_type string -- "weighted" or "center" ----@param sigma number ----@return love.Shader -local function buildShader(taps, offset, offset_type, sigma) - taps = math.floor(taps) - sigma = sigma >= 1 and sigma or (taps - 1) * offset / 6 - sigma = math.max(sigma, 1) - - local steps = (taps + 1) / 2 - - local g_offsets = {} - local g_weights = {} - for i = 1, steps, 1 do - g_offsets[i] = offset * (i - 1) - g_weights[i] = math.exp(-0.5 * (g_offsets[i] - 0) ^ 2 * 1 / sigma ^ 2) - end - - -- Calculate offsets and weights for sub-pixel samples - local offsets = {} - local weights = {} - for i = #g_weights, 2, -2 do - local oA, oB = g_offsets[i], g_offsets[i - 1] - local wA, wB = g_weights[i], g_weights[i - 1] - wB = oB == 0 and wB / 2 or wB - local weight = wA + wB - offsets[#offsets + 1] = offset_type == "center" and (oA + oB) / 2 or (oA * wA + oB * wB) / weight - weights[#weights + 1] = weight - end - - local code = { [[ - extern vec2 direction; - vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {]] } - - local norm = 0 - if #g_weights % 2 == 0 then - code[#code + 1] = "vec4 c = vec4( 0.0 );" - else - local weight = g_weights[1] - norm = norm + weight - code[#code + 1] = ("vec4 c = %f * texture2D(tex, tc);"):format(weight) - end - - local tmpl = "c += %f * ( texture2D(tex, tc + %f * direction)+ texture2D(tex, tc - %f * direction));\n" - for i = 1, #offsets, 1 do - local offset = offsets[i] - local weight = weights[i] - norm = norm + weight * 2 - code[#code + 1] = tmpl:format(weight, offset, offset) - end - code[#code + 1] = ("return c * vec4(%f) * color; }"):format(1 / norm) - - local shader = table.concat(code) - return love.graphics.newShader(shader) -end +local Cache = { + canvases = {}, + quads = {}, + MAX_CANVAS_SIZE = 20, + MAX_QUAD_SIZE = 20, +} --- Get or create a canvas from cache ----@param width number ----@param height number ----@return love.Canvas -local function getCanvas(width, height) +---@param width number Canvas width +---@param height number Canvas height +---@return love.Canvas canvas The cached or new canvas +function Cache.getCanvas(width, height) local key = string.format("%dx%d", width, height) - if not canvasCache[key] then - canvasCache[key] = {} + if not Cache.canvases[key] then + Cache.canvases[key] = {} end - local cache = canvasCache[key] + local cache = Cache.canvases[key] - for i, canvas in ipairs(cache) do - if not canvas.inUse then - canvas.inUse = true - return canvas.canvas + for i, entry in ipairs(cache) do + if not entry.inUse then + entry.inUse = true + return entry.canvas end end local canvas = love.graphics.newCanvas(width, height) table.insert(cache, { canvas = canvas, inUse = true }) - if #cache > MAX_CACHE_SIZE then + if #cache > Cache.MAX_CANVAS_SIZE then table.remove(cache, 1) end @@ -101,9 +36,9 @@ local function getCanvas(width, height) end --- Release a canvas back to the cache ----@param canvas love.Canvas -local function releaseCanvas(canvas) - for _, sizeCache in pairs(canvasCache) do +---@param canvas love.Canvas Canvas to release +function Cache.releaseCanvas(canvas) + for _, sizeCache in pairs(Cache.canvases) do for _, entry in ipairs(sizeCache) do if entry.canvas == canvas then entry.inUse = false @@ -114,21 +49,21 @@ local function releaseCanvas(canvas) end --- Get or create a quad from cache ----@param x number ----@param y number ----@param width number ----@param height number ----@param sw number -- Source width ----@param sh number -- Source height ----@return love.Quad -local function getQuad(x, y, width, height, sw, sh) +---@param x number X position +---@param y number Y position +---@param width number Quad width +---@param height number Quad height +---@param sw number Source width +---@param sh number Source height +---@return love.Quad quad The cached or new quad +function Cache.getQuad(x, y, width, height, sw, sh) local key = string.format("%d,%d,%d,%d,%d,%d", x, y, width, height, sw, sh) - if not quadCache[key] then - quadCache[key] = {} + if not Cache.quads[key] then + Cache.quads[key] = {} end - local cache = quadCache[key] + local cache = Cache.quads[key] for i, entry in ipairs(cache) do if not entry.inUse then @@ -140,7 +75,7 @@ local function getQuad(x, y, width, height, sw, sh) local quad = love.graphics.newQuad(x, y, width, height, sw, sh) table.insert(cache, { quad = quad, inUse = true }) - if #cache > MAX_QUAD_CACHE_SIZE then + if #cache > Cache.MAX_QUAD_SIZE then table.remove(cache, 1) end @@ -148,9 +83,9 @@ local function getQuad(x, y, width, height, sw, sh) end --- Release a quad back to the cache ----@param quad love.Quad -local function releaseQuad(quad) - for _, keyCache in pairs(quadCache) do +---@param quad love.Quad Quad to release +function Cache.releaseQuad(quad) + for _, keyCache in pairs(Cache.quads) do for _, entry in ipairs(keyCache) do if entry.quad == quad then entry.inUse = false @@ -160,11 +95,96 @@ local function releaseQuad(quad) end end ---- Create a blur effect instance ----@param quality number -- Quality level (1-10, higher = better quality but slower) ----@return table -- Blur effect instance -function Blur.new(quality) - quality = math.max(1, math.min(10, quality or 5)) +--- Clear all caches +function Cache.clear() + Cache.canvases = {} + Cache.quads = {} +end + +-- ============================================================================ +-- SHADER BUILDER +-- ============================================================================ + +local ShaderBuilder = {} + +--- Build Gaussian blur shader with given parameters +---@param taps number Number of samples (must be odd, >= 3) +---@param offset number Offset value +---@param offsetType string "weighted" or "center" +---@param sigma number Sigma value for Gaussian distribution +---@return love.Shader shader The compiled blur shader +function ShaderBuilder.build(taps, offset, offsetType, sigma) + taps = math.floor(taps) + sigma = sigma >= 1 and sigma or (taps - 1) * offset / 6 + sigma = math.max(sigma, 1) + + local steps = (taps + 1) / 2 + + local gOffsets = {} + local gWeights = {} + for i = 1, steps do + gOffsets[i] = offset * (i - 1) + gWeights[i] = math.exp(-0.5 * (gOffsets[i] - 0) ^ 2 * 1 / sigma ^ 2) + end + + local offsets = {} + local weights = {} + for i = #gWeights, 2, -2 do + local oA, oB = gOffsets[i], gOffsets[i - 1] + local wA, wB = gWeights[i], gWeights[i - 1] + wB = oB == 0 and wB / 2 or wB + local weight = wA + wB + offsets[#offsets + 1] = offsetType == "center" and (oA + oB) / 2 or (oA * wA + oB * wB) / weight + weights[#weights + 1] = weight + end + + local code = { + [[ + extern vec2 direction; + vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {]], + } + + local norm = 0 + if #gWeights % 2 == 0 then + code[#code + 1] = "vec4 c = vec4( 0.0 );" + else + local weight = gWeights[1] + norm = norm + weight + code[#code + 1] = string.format("vec4 c = %f * texture2D(tex, tc);", weight) + end + + local template = "c += %f * ( texture2D(tex, tc + %f * direction)+ texture2D(tex, tc - %f * direction));\n" + for i = 1, #offsets do + local offset = offsets[i] + local weight = weights[i] + norm = norm + weight * 2 + code[#code + 1] = string.format(template, weight, offset, offset) + end + code[#code + 1] = string.format("return c * vec4(%f) * color; }", 1 / norm) + + local shaderCode = table.concat(code) + return love.graphics.newShader(shaderCode) +end + +---@class BlurProps +---@field quality number? Quality level (1-10, default: 5) + +---@class Blur +---@field shader love.Shader The blur shader +---@field quality number Quality level (1-10) +---@field taps number Number of shader taps +---@field _ErrorHandler table? Reference to ErrorHandler module +local Blur = {} +Blur.__index = Blur + +--- Create a new blur effect instance +---@param props BlurProps? Blur configuration +---@return Blur blur The new blur instance +function Blur.new(props) + props = props or {} + + local quality = props.quality or 5 + quality = math.max(1, math.min(10, quality)) -- Map quality to shader parameters -- Quality 1: 3 taps (fastest, lowest quality) @@ -173,33 +193,38 @@ function Blur.new(quality) local taps = 3 + (quality - 1) * 1.5 taps = math.floor(taps) if taps % 2 == 0 then - taps = taps + 1 -- Ensure odd number + taps = taps + 1 end local offset = 1.0 - local offset_type = "weighted" + local offsetType = "weighted" local sigma = -1 - local shader = buildShader(taps, offset, offset_type, sigma) + local shader = ShaderBuilder.build(taps, offset, offsetType, sigma) - local instance = { - shader = shader, - quality = quality, - taps = taps, - } + local self = setmetatable({}, Blur) + self.shader = shader + self.quality = quality + self.taps = taps - return instance + return self end --- Apply blur to a region of the screen ----@param blurInstance table -- Blur effect instance from Blur.new() ----@param intensity number -- Blur intensity (0-100) ----@param x number -- X position ----@param y number -- Y position ----@param width number -- Width ----@param height number -- Height ----@param drawFunc function -- Function to draw content to be blurred -function Blur.applyToRegion(blurInstance, intensity, x, y, width, height, drawFunc) +---@param intensity number Blur intensity (0-100) +---@param x number X position +---@param y number Y position +---@param width number Width of region +---@param height number Height of region +---@param drawFunc function Function to draw content to be blurred +function Blur:applyToRegion(intensity, x, y, width, height, drawFunc) + if type(drawFunc) ~= "function" then + if Blur._ErrorHandler then + Blur._ErrorHandler:warn("Blur", "applyToRegion requires a draw function.") + end + return + end + if intensity <= 0 or width <= 0 or height <= 0 then drawFunc() return @@ -211,8 +236,8 @@ function Blur.applyToRegion(blurInstance, intensity, x, y, width, height, drawFu local passes = math.ceil(intensity / 20) passes = math.max(1, math.min(5, passes)) - local canvas1 = getCanvas(width, height) - local canvas2 = getCanvas(width, height) + local canvas1 = Cache.getCanvas(width, height) + local canvas2 = Cache.getCanvas(width, height) local prevCanvas = love.graphics.getCanvas() local prevShader = love.graphics.getShader() @@ -227,19 +252,19 @@ function Blur.applyToRegion(blurInstance, intensity, x, y, width, height, drawFu drawFunc() love.graphics.pop() - love.graphics.setShader(blurInstance.shader) + love.graphics.setShader(self.shader) love.graphics.setColor(1, 1, 1, 1) love.graphics.setBlendMode("alpha", "premultiplied") for i = 1, passes do love.graphics.setCanvas(canvas2) love.graphics.clear() - blurInstance.shader:send("direction", { 1 / width, 0 }) + self.shader:send("direction", { 1 / width, 0 }) love.graphics.draw(canvas1, 0, 0) love.graphics.setCanvas(canvas1) love.graphics.clear() - blurInstance.shader:send("direction", { 0, 1 / height }) + self.shader:send("direction", { 0, 1 / height }) love.graphics.draw(canvas2, 0, 0) end @@ -251,19 +276,25 @@ function Blur.applyToRegion(blurInstance, intensity, x, y, width, height, drawFu love.graphics.setShader(prevShader) love.graphics.setColor(unpack(prevColor)) - releaseCanvas(canvas1) - releaseCanvas(canvas2) + Cache.releaseCanvas(canvas1) + Cache.releaseCanvas(canvas2) end --- Apply backdrop blur effect (blur content behind a region) ----@param blurInstance table -- Blur effect instance from Blur.new() ----@param intensity number -- Blur intensity (0-100) ----@param x number -- X position ----@param y number -- Y position ----@param width number -- Width ----@param height number -- Height ----@param backdropCanvas love.Canvas -- Canvas containing the backdrop content -function Blur.applyBackdrop(blurInstance, intensity, x, y, width, height, backdropCanvas) +---@param intensity number Blur intensity (0-100) +---@param x number X position +---@param y number Y position +---@param width number Width of region +---@param height number Height of region +---@param backdropCanvas love.Canvas Canvas containing the backdrop content +function Blur:applyBackdrop(intensity, x, y, width, height, backdropCanvas) + if not backdropCanvas then + if Blur._ErrorHandler then + Blur._ErrorHandler:warn("Blur", "applyBackdrop requires a backdrop canvas.") + end + return + end + if intensity <= 0 or width <= 0 or height <= 0 then return end @@ -273,8 +304,8 @@ function Blur.applyBackdrop(blurInstance, intensity, x, y, width, height, backdr local passes = math.ceil(intensity / 20) passes = math.max(1, math.min(5, passes)) - local canvas1 = getCanvas(width, height) - local canvas2 = getCanvas(width, height) + local canvas1 = Cache.getCanvas(width, height) + local canvas2 = Cache.getCanvas(width, height) local prevCanvas = love.graphics.getCanvas() local prevShader = love.graphics.getShader() @@ -287,20 +318,20 @@ function Blur.applyBackdrop(blurInstance, intensity, x, y, width, height, backdr love.graphics.setBlendMode("alpha", "premultiplied") local backdropWidth, backdropHeight = backdropCanvas:getDimensions() - local quad = getQuad(x, y, width, height, backdropWidth, backdropHeight) + local quad = Cache.getQuad(x, y, width, height, backdropWidth, backdropHeight) love.graphics.draw(backdropCanvas, quad, 0, 0) - love.graphics.setShader(blurInstance.shader) + love.graphics.setShader(self.shader) for i = 1, passes do love.graphics.setCanvas(canvas2) love.graphics.clear() - blurInstance.shader:send("direction", { 1 / width, 0 }) + self.shader:send("direction", { 1 / width, 0 }) love.graphics.draw(canvas1, 0, 0) love.graphics.setCanvas(canvas1) love.graphics.clear() - blurInstance.shader:send("direction", { 0, 1 / height }) + self.shader:send("direction", { 0, 1 / height }) love.graphics.draw(canvas2, 0, 0) end @@ -312,15 +343,35 @@ function Blur.applyBackdrop(blurInstance, intensity, x, y, width, height, backdr love.graphics.setShader(prevShader) love.graphics.setColor(unpack(prevColor)) - releaseCanvas(canvas1) - releaseCanvas(canvas2) - releaseQuad(quad) + Cache.releaseCanvas(canvas1) + Cache.releaseCanvas(canvas2) + Cache.releaseQuad(quad) end ---- Clear canvas cache (call on window resize) -function Blur.clearCache() - canvasCache = {} - quadCache = {} +--- Get the current quality level +---@return number quality Quality level (1-10) +function Blur:getQuality() + return self.quality end +--- Get the number of shader taps +---@return number taps Number of shader taps +function Blur:getTaps() + return self.taps +end + +--- Clear all caches (call on window resize or memory cleanup) +function Blur.clearCache() + Cache.clear() +end + +--- Initialize Blur module with dependencies +---@param deps table Dependencies: { ErrorHandler = ErrorHandler? } +function Blur.init(deps) + Blur._ErrorHandler = deps.ErrorHandler +end + +Blur.Cache = Cache +Blur.ShaderBuilder = ShaderBuilder + return Blur diff --git a/modules/Color.lua b/modules/Color.lua index c1c72ba..2ad30fe 100644 --- a/modules/Color.lua +++ b/modules/Color.lua @@ -1,36 +1,3 @@ -local ErrorHandler = nil - --- Named colors (CSS3 color names) -local NAMED_COLORS = { - -- Basic colors - black = { 0, 0, 0, 1 }, - white = { 1, 1, 1, 1 }, - red = { 1, 0, 0, 1 }, - green = { 0, 0.502, 0, 1 }, - blue = { 0, 0, 1, 1 }, - yellow = { 1, 1, 0, 1 }, - cyan = { 0, 1, 1, 1 }, - magenta = { 1, 0, 1, 1 }, - - -- Extended colors - gray = { 0.502, 0.502, 0.502, 1 }, - grey = { 0.502, 0.502, 0.502, 1 }, - silver = { 0.753, 0.753, 0.753, 1 }, - maroon = { 0.502, 0, 0, 1 }, - olive = { 0.502, 0.502, 0, 1 }, - lime = { 0, 1, 0, 1 }, - aqua = { 0, 1, 1, 1 }, - teal = { 0, 0.502, 0.502, 1 }, - navy = { 0, 0, 0.502, 1 }, - fuchsia = { 1, 0, 1, 1 }, - purple = { 0.502, 0, 0.502, 1 }, - orange = { 1, 0.647, 0, 1 }, - pink = { 1, 0.753, 0.796, 1 }, - brown = { 0.647, 0.165, 0.165, 1 }, - transparent = { 0, 0, 0, 0 }, -} - ---- Utility class for color handling ---@class Color ---@field r number -- Red component (0-1) ---@field g number -- Green component (0-1) @@ -79,13 +46,11 @@ end function Color.fromHex(hexWithTag) -- Validate input type if type(hexWithTag) ~= "string" then - if ErrorHandler then - ErrorHandler.warn("Color", "VAL_004", "Invalid color format", { - input = tostring(hexWithTag), - issue = "not a string", - fallback = "white (#FFFFFF)", - }) - end + Color._ErrorHandler.warn("Color", "VAL_004", "Invalid color format", { + input = tostring(hexWithTag), + issue = "not a string", + fallback = "white (#FFFFFF)", + }) return Color.new(1, 1, 1, 1) end @@ -95,13 +60,11 @@ function Color.fromHex(hexWithTag) local g = tonumber("0x" .. hex:sub(3, 4)) local b = tonumber("0x" .. hex:sub(5, 6)) if not r or not g or not b then - if ErrorHandler then - ErrorHandler.warn("Color", "VAL_004", "Invalid color format", { - input = hexWithTag, - issue = "invalid hex digits", - fallback = "white (#FFFFFF)", - }) - end + Color._ErrorHandler.warn("Color", "VAL_004", "Invalid color format", { + input = hexWithTag, + issue = "invalid hex digits", + fallback = "white (#FFFFFF)", + }) return Color.new(1, 1, 1, 1) -- Return white as fallback end return Color.new(r / 255, g / 255, b / 255, 1) @@ -111,25 +74,21 @@ function Color.fromHex(hexWithTag) local b = tonumber("0x" .. hex:sub(5, 6)) local a = tonumber("0x" .. hex:sub(7, 8)) if not r or not g or not b or not a then - if ErrorHandler then - ErrorHandler.warn("Color", "VAL_004", "Invalid color format", { - input = hexWithTag, - issue = "invalid hex digits", - fallback = "white (#FFFFFFFF)", - }) - end + Color._ErrorHandler.warn("Color", "VAL_004", "Invalid color format", { + input = hexWithTag, + issue = "invalid hex digits", + fallback = "white (#FFFFFFFF)", + }) return Color.new(1, 1, 1, 1) -- Return white as fallback end return Color.new(r / 255, g / 255, b / 255, a / 255) else - if ErrorHandler then - ErrorHandler.warn("Color", "VAL_004", "Invalid color format", { - input = hexWithTag, - expected = "#RRGGBB or #RRGGBBAA", - hexLength = #hex, - fallback = "white (#FFFFFF)", - }) - end + Color._ErrorHandler.warn("Color", "VAL_004", "Invalid color format", { + input = hexWithTag, + expected = "#RRGGBB or #RRGGBBAA", + hexLength = #hex, + fallback = "white (#FFFFFF)", + }) return Color.new(1, 1, 1, 1) -- Return white as fallback end end @@ -227,23 +186,6 @@ function Color.validateRGBColor(r, g, b, a, max) return true, nil end ---- Validate named color ----@param name string Color name (e.g. "red", "blue", "transparent") ----@return boolean valid True if valid ----@return string? error Error message if invalid, nil if valid -function Color.validateNamedColor(name) - if type(name) ~= "string" then - return false, "Color name must be a string" - end - - local lowerName = name:lower() - if not NAMED_COLORS[lowerName] then - return false, string.format("Unknown color name: '%s'", name) - end - - return true, nil -end - --- Check if a value is a valid color format ---@param value any Value to check ---@return string? format Format type ("hex", "named", "table"), nil if invalid @@ -259,11 +201,6 @@ function Color.isValidColorFormat(value) end end - -- Check for named color - if NAMED_COLORS[value:lower()] then - return "named" - end - return nil end @@ -296,42 +233,6 @@ function Color.isValidColorFormat(value) return nil end ---- Check if a color value is usable before processing to provide clear error messages ---- Use this for config validation and debugging malformed color data ----@param value any Color value to validate ----@param options table? Validation options {allowNamed: boolean, requireAlpha: boolean} ----@return boolean valid True if valid ----@return string? error Error message if invalid, nil if valid -function Color.validateColor(value, options) - options = options or {} - local allowNamed = options.allowNamed ~= false - local requireAlpha = options.requireAlpha or false - - if value == nil then - return false, "Color value is nil" - end - - local format = Color.isValidColorFormat(value) - - if not format then - return false, string.format("Invalid color format: %s", tostring(value)) - end - - if format == "named" and not allowNamed then - return false, "Named colors not allowed" - end - - -- Additional validation for alpha requirement - if requireAlpha and format == "hex" then - local cleanHex = value:gsub("^#", "") - if #cleanHex ~= 8 then - return false, "Alpha channel required (use 8-digit hex)" - end - end - - return true, nil -end - --- Convert any color format to a valid Color object with graceful fallbacks --- Use this to robustly handle colors from any source without crashes ---@param value any Color value to sanitize (hex, named, table, or Color instance) @@ -364,17 +265,6 @@ function Color.sanitizeColor(value, default) end end - -- Handle named format - if format == "named" then - local lowerName = value:lower() - local rgba = NAMED_COLORS[lowerName] - if rgba then - return Color.new(rgba[1], rgba[2], rgba[3], rgba[4]) - end - return default - end - - -- Handle table format if format == "table" then -- Color instance if getmetatable(value) == Color then @@ -450,9 +340,7 @@ end --- Initialize dependencies ---@param deps table Dependencies: { ErrorHandler = ErrorHandler } function Color.init(deps) - if type(deps) == "table" then - ErrorHandler = deps.ErrorHandler - end + Color._ErrorHandler = deps.ErrorHandler end return Color diff --git a/modules/Context.lua b/modules/Context.lua index 02a64b6..a7272a0 100644 --- a/modules/Context.lua +++ b/modules/Context.lua @@ -1,26 +1,14 @@ ---@class Context local Context = { - -- Top-level elements topElements = {}, - -- Base scale configuration baseScale = nil, -- {width: number, height: number} - -- Current scale factors scaleFactors = { x = 1.0, y = 1.0 }, - - -- Default theme name defaultTheme = nil, - - -- Currently focused element (for keyboard input) _focusedElement = nil, - - -- Active event element (for current frame) _activeEventElement = nil, - - -- Cached viewport dimensions _cachedViewport = { width = 0, height = 0 }, - -- Immediate mode state _immediateMode = false, _frameNumber = 0, @@ -28,12 +16,10 @@ local Context = { _immediateModeState = nil, -- Will be initialized if immediate mode is enabled _frameStarted = false, _autoBeganFrame = false, - -- Z-index ordered element tracking for immediate mode _zIndexOrderedElements = {}, -- Array of elements sorted by z-index (lowest to highest) } ---- Get current scale factors ---@return number, number -- scaleX, scaleY function Context.getScaleFactors() return Context.scaleFactors.x, Context.scaleFactors.y @@ -49,7 +35,6 @@ function Context.registerElement(element) table.insert(Context._zIndexOrderedElements, element) end ---- Clear frame elements (called at start of each immediate mode frame) function Context.clearFrameElements() Context._zIndexOrderedElements = {} end @@ -88,7 +73,7 @@ local function isPointInElement(element, x, y) -- Calculate scroll offset from parent chain local scrollOffsetX = 0 local scrollOffsetY = 0 - + -- Walk up parent chain to check clipping and accumulate scroll offsets local current = element.parent while current do @@ -105,7 +90,7 @@ local function isPointInElement(element, x, y) if x < parentX or x > parentX + parentW or y < parentY or y > parentY + parentH then return false -- Point is clipped by parent end - + -- Accumulate scroll offset scrollOffsetX = scrollOffsetX + (current._scrollX or 0) scrollOffsetY = scrollOffsetY + (current._scrollY or 0) @@ -143,7 +128,6 @@ function Context.getTopElementAt(x, y) return nil end - -- Traverse from highest to lowest z-index (reverse order) for i = #Context._zIndexOrderedElements, 1, -1 do local element = Context._zIndexOrderedElements[i] @@ -152,7 +136,6 @@ function Context.getTopElementAt(x, y) if interactive then return interactive end - -- This preserves backward compatibility for non-interactive overlays return element end end diff --git a/modules/EventHandler.lua b/modules/EventHandler.lua index 18b1f81..82906e5 100644 --- a/modules/EventHandler.lua +++ b/modules/EventHandler.lua @@ -219,7 +219,7 @@ function EventHandler:processMouseEvents(mx, my, isHovering, isActiveElement) end end end - + -- Stop performance timing if Performance and Performance.isEnabled() then Performance.stopTimer("event_mouse") @@ -452,10 +452,10 @@ function EventHandler:processTouchEvents() local touchId = tostring(id) local tx, ty = love.touch.getPosition(id) local pressure = 1.0 -- LÖVE doesn't provide pressure by default - + -- Check if touch is within element bounds local isInside = tx >= bx and tx <= bx + bw and ty >= by and ty <= by + bh - + if isInside then if not self._touches[touchId] then -- New touch began diff --git a/modules/Performance.lua b/modules/Performance.lua index 5d8c360..c4d7780 100644 --- a/modules/Performance.lua +++ b/modules/Performance.lua @@ -1,163 +1,119 @@ ---- Performance monitoring module for FlexLove ---- Provides timing, profiling, and performance metrics ---@class Performance +---@field enabled boolean +---@field hudEnabled boolean +---@field hudToggleKey string +---@field hudPosition {x: number, y: number} +---@field warningThresholdMs number +---@field criticalThresholdMs number +---@field logToConsole boolean +---@field logWarnings boolean +---@field warningsEnabled boolean +---@field _ErrorHandler table? +---@field _timers table +---@field _metrics table +---@field _lastMetricsCleanup number +---@field _frameMetrics table +---@field _memoryMetrics table +---@field _warnings table +---@field _lastFrameStart number? +---@field _shownWarnings table +---@field _memoryProfiler table local Performance = {} +Performance.__index = Performance --- Load ErrorHandler (with fallback if not available) -local ErrorHandler = nil -local ErrorHandlerInitialized = false +---@type Performance|nil +local instance = nil -local function getErrorHandler() - if not ErrorHandler then - local success, module = pcall(require, "modules.ErrorHandler") - if success then - ErrorHandler = module - - -- Initialize ErrorHandler with ErrorCodes if not already initialized - if not ErrorHandlerInitialized then - local successCodes, ErrorCodes = pcall(require, "modules.ErrorCodes") - if successCodes and ErrorHandler.init then - ErrorHandler.init({ErrorCodes = ErrorCodes}) - end - ErrorHandlerInitialized = true - end - end +local METRICS_CLEANUP_INTERVAL = 30 +local METRICS_RETENTION_TIME = 10 +local MAX_METRICS_COUNT = 500 +local CORE_METRICS = { frame = true, layout = true, render = true } + +---@param config {enabled?: boolean, hudEnabled?: boolean, hudToggleKey?: string, hudPosition?: {x: number, y: number}, warningThresholdMs?: number, criticalThresholdMs?: number, logToConsole?: boolean, logWarnings?: boolean, warningsEnabled?: boolean}? +---@param deps {ErrorHandler: ErrorHandler} +---@return Performance +function Performance.init(config, deps) + if instance == nil then + local self = setmetatable({}, Performance) + + -- Configuration + self.enabled = config and config.enabled or false + self.hudEnabled = config and config.hudEnabled or false + self.hudToggleKey = config and config.hudToggleKey or "f3" + self.hudPosition = config and config.hudPosition or { x = 10, y = 10 } + self.warningThresholdMs = config and config.warningThresholdMs or 13.0 + self.criticalThresholdMs = config and config.criticalThresholdMs or 16.67 + self.logToConsole = config and config.logToConsole or false + self.logWarnings = config and config.logWarnings or true + self.warningsEnabled = config and config.warningsEnabled or true + + self._timers = {} + self._metrics = {} + self._lastMetricsCleanup = 0 + self._frameMetrics = { + frameCount = 0, + totalTime = 0, + lastFrameTime = 0, + minFrameTime = math.huge, + maxFrameTime = 0, + fps = 0, + lastFpsUpdate = 0, + fpsUpdateInterval = 0.5, + } + self._memoryMetrics = { + current = 0, + peak = 0, + gcCount = 0, + lastGcCheck = 0, + } + self._warnings = {} + self._lastFrameStart = nil + self._shownWarnings = {} + self._memoryProfiler = { + enabled = false, + sampleInterval = 60, + framesSinceLastSample = 0, + samples = {}, + maxSamples = 20, + monitoredTables = {}, + } + self._ErrorHandler = deps and deps.ErrorHandler + instance = self end - return ErrorHandler + return instance end --- Configuration -local config = { - enabled = false, - hudEnabled = false, - hudToggleKey = "f3", - hudPosition = { x = 10, y = 10 }, - warningThresholdMs = 13.0, -- Yellow warning - criticalThresholdMs = 16.67, -- Red warning (60 FPS) - logToConsole = false, - logWarnings = true, - warningsEnabled = true, -} - --- Metrics cleanup configuration -local METRICS_CLEANUP_INTERVAL = 30 -- Cleanup every 30 seconds (more aggressive) -local METRICS_RETENTION_TIME = 10 -- Keep metrics used in last 10 seconds -local MAX_METRICS_COUNT = 500 -- Maximum number of unique metrics -local CORE_METRICS = { frame = true, layout = true, render = true } -- Never cleanup these - --- State -local timers = {} -- Active timers {name -> startTime} -local metrics = {} -- Accumulated metrics {name -> {total, count, min, max, lastUsed}} -local lastMetricsCleanup = 0 -- Last time metrics were cleaned up -local frameMetrics = { - frameCount = 0, - totalTime = 0, - lastFrameTime = 0, - minFrameTime = math.huge, - maxFrameTime = 0, - fps = 0, - lastFpsUpdate = 0, - fpsUpdateInterval = 0.5, -- Update FPS every 0.5s -} -local memoryMetrics = { - current = 0, - peak = 0, - gcCount = 0, - lastGcCheck = 0, -} -local warnings = {} -local lastFrameStart = nil -local shownWarnings = {} -- Track warnings that have been shown (dedupe) - --- Memory profiling state -local memoryProfiler = { - enabled = false, - sampleInterval = 60, -- Frames between samples - framesSinceLastSample = 0, - samples = {}, -- Array of {time, memory, tableSizes} - maxSamples = 20, -- Keep last 20 samples (~20 seconds at 60fps) - monitoredTables = {}, -- Tables to monitor (added via registerTable) -} - ---- Initialize performance monitoring ---- @param options table? Optional configuration overrides -function Performance.init(options) - if options then - for k, v in pairs(options) do - config[k] = v - end - end - Performance.reset() +--- Toggle HUD visibility +function Performance:toggleHUD() + self.hudEnabled = not self.hudEnabled end ---- Enable performance monitoring -function Performance.enable() - config.enabled = true -end - ---- Disable performance monitoring -function Performance.disable() - config.enabled = false -end - ---- Check if performance monitoring is enabled ---- @return boolean -function Performance.isEnabled() - return config.enabled -end - ---- Toggle performance HUD -function Performance.toggleHUD() - config.hudEnabled = not config.hudEnabled -end - ---- Reset all metrics -function Performance.reset() - timers = {} - metrics = {} - warnings = {} - shownWarnings = {} - frameMetrics.frameCount = 0 - frameMetrics.totalTime = 0 - frameMetrics.lastFrameTime = 0 - frameMetrics.minFrameTime = math.huge - frameMetrics.maxFrameTime = 0 - memoryMetrics.current = 0 - memoryMetrics.peak = 0 - memoryMetrics.gcCount = 0 -end - ---- Start a named timer ---- @param name string Timer name -function Performance.startTimer(name) - if not config.enabled then +function Performance:startTimer(name) + if not self.enabled then return end - timers[name] = love.timer.getTime() + self._timers[name] = love.timer.getTime() end ---- Stop a named timer and record the elapsed time ---- @param name string Timer name ---- @return number? elapsedMs Elapsed time in milliseconds, or nil if timer not found -function Performance.stopTimer(name) - if not config.enabled then +function Performance:stopTimer(name) + if not self.enabled then return nil end - local startTime = timers[name] + local startTime = self._timers[name] if not startTime then - if config.logWarnings then + if self.logWarnings then print(string.format("[Performance] Warning: Timer '%s' was not started", name)) end return nil end - local elapsed = (love.timer.getTime() - startTime) * 1000 -- Convert to ms - timers[name] = nil + local elapsed = (love.timer.getTime() - startTime) * 1000 + self._timers[name] = nil -- Update metrics - if not metrics[name] then - metrics[name] = { + if not self._metrics[name] then + self._metrics[name] = { total = 0, count = 0, min = math.huge, @@ -167,7 +123,7 @@ function Performance.stopTimer(name) } end - local m = metrics[name] + local m = self._metrics[name] m.total = m.total + elapsed m.count = m.count + 1 m.min = math.min(m.min, elapsed) @@ -176,111 +132,83 @@ function Performance.stopTimer(name) m.lastUsed = love.timer.getTime() -- Check for warnings - if elapsed > config.criticalThresholdMs then - Performance.addWarning(name, elapsed, "critical") - elseif elapsed > config.warningThresholdMs then - Performance.addWarning(name, elapsed, "warning") + if elapsed > self.criticalThresholdMs then + self:addWarning(name, elapsed, "critical") + elseif elapsed > self.warningThresholdMs then + self:addWarning(name, elapsed, "warning") end - if config.logToConsole then + if self.logToConsole then print(string.format("[Performance] %s: %.3fms", name, elapsed)) end return elapsed end ---- Wrap a function with performance timing ---- @param name string Metric name ---- @param fn function Function to measure ---- @return function Wrapped function -function Performance.measure(name, fn) - if not config.enabled then - return fn - end - - return function(...) - Performance.startTimer(name) - local results = table.pack(fn(...)) - Performance.stopTimer(name) - return table.unpack(results, 1, results.n) - end -end - --- Update with actual delta time from LÖVE (call from love.update) ---@param dt number Delta time in seconds -function Performance.updateDeltaTime(dt) - if not config.enabled then +function Performance:updateDeltaTime(dt) + if not self.enabled then return end - local now = love.timer.getTime() - - -- Update FPS from actual delta time (not processing time) - if now - frameMetrics.lastFpsUpdate >= frameMetrics.fpsUpdateInterval then + if now - self._frameMetrics.lastFpsUpdate >= self._frameMetrics.fpsUpdateInterval then if dt > 0 then - frameMetrics.fps = math.floor(1 / dt + 0.5) + self._frameMetrics.fps = math.floor(1 / dt + 0.5) end - frameMetrics.lastFpsUpdate = now + self._frameMetrics.lastFpsUpdate = now end end --- Start frame timing (call at beginning of frame) -function Performance.startFrame() - if not config.enabled then +function Performance:startFrame() + if not self.enabled then return end - lastFrameStart = love.timer.getTime() - Performance.updateMemory() + self._lastFrameStart = love.timer.getTime() + self:_updateMemory() end ---- End frame timing (call at end of frame) -function Performance.endFrame() - if not config.enabled or not lastFrameStart then +function Performance:endFrame() + if not self.enabled or not self._lastFrameStart then return end local now = love.timer.getTime() - local frameTime = (now - lastFrameStart) * 1000 -- ms + local frameTime = (now - self._lastFrameStart) * 1000 - frameMetrics.lastFrameTime = frameTime - frameMetrics.totalTime = frameMetrics.totalTime + frameTime - frameMetrics.frameCount = frameMetrics.frameCount + 1 - frameMetrics.minFrameTime = math.min(frameMetrics.minFrameTime, frameTime) - frameMetrics.maxFrameTime = math.max(frameMetrics.maxFrameTime, frameTime) + self._frameMetrics.lastFrameTime = frameTime + self._frameMetrics.totalTime = self._frameMetrics.totalTime + frameTime + self._frameMetrics.frameCount = self._frameMetrics.frameCount + 1 + self._frameMetrics.minFrameTime = math.min(self._frameMetrics.minFrameTime, frameTime) + self._frameMetrics.maxFrameTime = math.max(self._frameMetrics.maxFrameTime, frameTime) - -- Note: FPS is now calculated from actual delta time in updateDeltaTime() - -- frameTime here represents processing time, not actual frame rate - - -- Check for frame drops - if frameTime > config.criticalThresholdMs then - Performance.addWarning("frame", frameTime, "critical") + if frameTime > self.criticalThresholdMs then + self:_addWarning("frame", frameTime, "critical") end - -- Update memory profiling - Performance.updateMemoryProfiling() + self:updateMemoryProfiling() - -- Periodic metrics cleanup (every 30 seconds, more aggressive) - if now - lastMetricsCleanup >= METRICS_CLEANUP_INTERVAL then + -- Periodic metrics cleanup + if now - self._lastMetricsCleanup >= METRICS_CLEANUP_INTERVAL then local cleanupTime = now - METRICS_RETENTION_TIME - for name, data in pairs(metrics) do - -- Don't cleanup core metrics + for name, data in pairs(self._metrics) do if not CORE_METRICS[name] and data.lastUsed and data.lastUsed < cleanupTime then - metrics[name] = nil + self._metrics[name] = nil end end - lastMetricsCleanup = now + self._lastMetricsCleanup = now end -- Enforce max metrics limit local metricsCount = 0 - for _ in pairs(metrics) do + for _ in pairs(self._metrics) do metricsCount = metricsCount + 1 end if metricsCount > MAX_METRICS_COUNT then - -- Find and remove oldest non-core metrics local sortedMetrics = {} - for name, data in pairs(metrics) do + for name, data in pairs(self._metrics) do if not CORE_METRICS[name] then table.insert(sortedMetrics, { name = name, lastUsed = data.lastUsed or 0 }) end @@ -290,38 +218,36 @@ function Performance.endFrame() return a.lastUsed < b.lastUsed end) - -- Remove oldest metrics until we're under the limit local toRemove = metricsCount - MAX_METRICS_COUNT for i = 1, math.min(toRemove, #sortedMetrics) do - metrics[sortedMetrics[i].name] = nil + self._metrics[sortedMetrics[i].name] = nil end end end --- Update memory metrics -function Performance.updateMemory() - if not config.enabled then +function Performance:_updateMemory() + if not self.enabled then return end local memKb = collectgarbage("count") - memoryMetrics.current = memKb - memoryMetrics.peak = math.max(memoryMetrics.peak, memKb) + self._memoryMetrics.current = memKb + self._memoryMetrics.peak = math.max(self._memoryMetrics.peak, memKb) - -- Track GC cycles local now = love.timer.getTime() - if now - memoryMetrics.lastGcCheck >= 1.0 then - memoryMetrics.gcCount = memoryMetrics.gcCount + 1 - memoryMetrics.lastGcCheck = now + if now - self._memoryMetrics.lastGcCheck >= 1.0 then + self._memoryMetrics.gcCount = self._memoryMetrics.gcCount + 1 + self._memoryMetrics.lastGcCheck = now end end ---- Add a performance warning +--- Add a performance warning (private) --- @param name string Metric name --- @param value number Metric value --- @param level "warning"|"critical" Warning level -function Performance.addWarning(name, value, level) - if not config.logWarnings then +function Performance:_addWarning(name, value, level) + if not self.logWarnings then return end @@ -332,214 +258,98 @@ function Performance.addWarning(name, value, level) time = love.timer.getTime(), } - table.insert(warnings, warning) + table.insert(self._warnings, warning) - -- Keep only last 100 warnings - if #warnings > 100 then - table.remove(warnings, 1) + if #self._warnings > 100 then + table.remove(self._warnings, 1) end - -- Log to console if enabled (with deduplication per metric name) - if config.logToConsole or config.warningsEnabled then + if self.logToConsole or self.warningsEnabled then local warningKey = name .. "_" .. level - - -- Only log each warning type once per 60 seconds to avoid spam - local lastWarningTime = shownWarnings[warningKey] or 0 + local lastWarningTime = self._shownWarnings[warningKey] or 0 local now = love.timer.getTime() if now - lastWarningTime >= 60 then - -- Route through ErrorHandler for consistent logging - local EH = getErrorHandler() - - if EH and EH.warn then + if self._ErrorHandler and self._ErrorHandler.warn then local message = string.format("%s = %.2fms", name, value) local code = level == "critical" and "PERF_002" or "PERF_001" - local suggestion = level == "critical" - and "This operation is causing frame drops. Consider optimizing or reducing frequency." + local suggestion = level == "critical" and "This operation is causing frame drops. Consider optimizing or reducing frequency." or "This operation is taking longer than recommended. Monitor for patterns." - - EH.warn("Performance", code, message, { + + self._ErrorHandler:warn("Performance", code, message, { metric = name, value = string.format("%.2fms", value), - threshold = level == "critical" and config.criticalThresholdMs or config.warningThresholdMs, + threshold = level == "critical" and self.criticalThresholdMs or self.warningThresholdMs, }, suggestion) else - -- Fallback to direct print if ErrorHandler not available local prefix = level == "critical" and "[CRITICAL]" or "[WARNING]" - local message = string.format("%s Performance: %s = %.2fms", prefix, name, value) - print(message) + print(string.format("%s Performance: %s = %.2fms", prefix, name, value)) end - - shownWarnings[warningKey] = now + + self._shownWarnings[warningKey] = now end end end ---- Get current FPS ---- @return number fps Frames per second -function Performance.getFPS() - return frameMetrics.fps -end - ---- Get frame metrics ---- @return table frameMetrics Frame timing data -function Performance.getFrameMetrics() - return { - fps = frameMetrics.fps, - lastFrameTime = frameMetrics.lastFrameTime, - minFrameTime = frameMetrics.minFrameTime, - maxFrameTime = frameMetrics.maxFrameTime, - averageFrameTime = frameMetrics.frameCount > 0 and frameMetrics.totalTime / frameMetrics.frameCount or 0, - frameCount = frameMetrics.frameCount, - } -end - ---- Get memory metrics ---- @return table memoryMetrics Memory usage data -function Performance.getMemoryMetrics() - Performance.updateMemory() - return { - currentKb = memoryMetrics.current, - currentMb = memoryMetrics.current / 1024, - peakKb = memoryMetrics.peak, - peakMb = memoryMetrics.peak / 1024, - gcCount = memoryMetrics.gcCount, - } -end - ---- Get all performance metrics ---- @return table metrics All collected metrics -function Performance.getMetrics() - local result = { - frame = Performance.getFrameMetrics(), - memory = Performance.getMemoryMetrics(), - timings = {}, - } - - for name, data in pairs(metrics) do - result.timings[name] = { - average = data.average, - min = data.min, - max = data.max, - total = data.total, - count = data.count, - } - end - - return result -end - ---- Get recent warnings ---- @param count number? Number of warnings to return (default: 10) ---- @return table warnings Recent warnings -function Performance.getWarnings(count) - count = count or 10 - local result = {} - local start = math.max(1, #warnings - count + 1) - for i = start, #warnings do - table.insert(result, warnings[i]) - end - return result -end - ---- Export metrics to JSON format ---- @return string json JSON string of metrics -function Performance.exportJSON() - local allMetrics = Performance.getMetrics() - -- Simple JSON encoding (for more complex needs, use a JSON library) - local json = "{\n" - json = json .. string.format(' "fps": %d,\n', allMetrics.frame.fps) - json = json .. string.format(' "averageFrameTime": %.3f,\n', allMetrics.frame.averageFrameTime) - json = json .. string.format(' "memoryMb": %.2f,\n', allMetrics.memory.currentMb) - json = json .. ' "timings": {\n' - - local timingPairs = {} - for name, data in pairs(allMetrics.timings) do - table.insert( - timingPairs, - string.format(' "%s": {"average": %.3f, "min": %.3f, "max": %.3f, "count": %d}', name, data.average, data.min, data.max, data.count) - ) - end - json = json .. table.concat(timingPairs, ",\n") .. "\n" - - json = json .. " }\n" - json = json .. "}" - return json -end - ---- Export metrics to CSV format ---- @return string csv CSV string of metrics -function Performance.exportCSV() - local csv = "Name,Average (ms),Min (ms),Max (ms),Count\n" - for name, data in pairs(metrics) do - csv = csv .. string.format("%s,%.3f,%.3f,%.3f,%d\n", name, data.average, data.min, data.max, data.count) - end - return csv -end - --- Render performance HUD --- @param x number? X position (default: 10) --- @param y number? Y position (default: 10) -function Performance.renderHUD(x, y) - if not config.hudEnabled then +function Performance:renderHUD(x, y) + if not self.hudEnabled then return end - -- Use config position if x/y not provided - x = x or config.hudPosition.x - y = y or config.hudPosition.y + x = x or self.hudPosition.x + y = y or self.hudPosition.y - local fm = Performance.getFrameMetrics() - local mm = Performance.getMemoryMetrics() + self:_updateMemory() + + local fm = self._frameMetrics + local mm = self._memoryMetrics - -- Background love.graphics.setColor(0, 0, 0, 0.8) love.graphics.rectangle("fill", x, y, 300, 220) - -- Text love.graphics.setColor(1, 1, 1, 1) local lineHeight = 18 local currentY = y + 10 -- FPS local fpsColor = { 1, 1, 1 } - if fm.lastFrameTime > config.criticalThresholdMs then - fpsColor = { 1, 0, 0 } -- Red - elseif fm.lastFrameTime > config.warningThresholdMs then - fpsColor = { 1, 1, 0 } -- Yellow + if fm.lastFrameTime > self.criticalThresholdMs then + fpsColor = { 1, 0, 0 } + elseif fm.lastFrameTime > self.warningThresholdMs then + fpsColor = { 1, 1, 0 } end love.graphics.setColor(fpsColor) love.graphics.print(string.format("FPS: %d (%.2fms)", fm.fps, fm.lastFrameTime), x + 10, currentY) currentY = currentY + lineHeight - -- Frame times love.graphics.setColor(1, 1, 1, 1) - love.graphics.print(string.format("Avg Frame: %.2fms", fm.averageFrameTime), x + 10, currentY) + local avgFrame = fm.frameCount > 0 and fm.totalTime / fm.frameCount or 0 + love.graphics.print(string.format("Avg Frame: %.2fms", avgFrame), x + 10, currentY) currentY = currentY + lineHeight love.graphics.print(string.format("Min/Max: %.2f/%.2fms", fm.minFrameTime, fm.maxFrameTime), x + 10, currentY) currentY = currentY + lineHeight - -- Memory - love.graphics.print(string.format("Memory: %.2f MB (peak: %.2f MB)", mm.currentMb, mm.peakMb), x + 10, currentY) + local currentMb = mm.current / 1024 + local peakMb = mm.peak / 1024 + love.graphics.print(string.format("Memory: %.2f MB (peak: %.2f MB)", currentMb, peakMb), x + 10, currentY) currentY = currentY + lineHeight - -- Metrics count local metricsCount = 0 - for _ in pairs(metrics) do + for _ in pairs(self._metrics) do metricsCount = metricsCount + 1 end local metricsColor = metricsCount > MAX_METRICS_COUNT * 0.8 and { 1, 0.5, 0 } or { 1, 1, 1 } love.graphics.setColor(metricsColor) love.graphics.print(string.format("Metrics: %d/%d", metricsCount, MAX_METRICS_COUNT), x + 10, currentY) - currentY = currentY + lineHeight - - -- Separator - love.graphics.setColor(1, 1, 1, 1) - currentY = currentY + 5 + currentY = currentY + lineHeight + 5 -- Top timings + love.graphics.setColor(1, 1, 1, 1) local sortedMetrics = {} - for name, data in pairs(metrics) do + for name, data in pairs(self._metrics) do table.insert(sortedMetrics, { name = name, average = data.average }) end table.sort(sortedMetrics, function(a, b) @@ -555,74 +365,48 @@ function Performance.renderHUD(x, y) currentY = currentY + lineHeight end - -- Warnings count - if #warnings > 0 then + if #self._warnings > 0 then love.graphics.setColor(1, 0.5, 0, 1) - love.graphics.print(string.format("Warnings: %d", #warnings), x + 10, currentY) + love.graphics.print(string.format("Warnings: %d", #self._warnings), x + 10, currentY) end end --- Handle keyboard input for HUD toggle --- @param key string Key pressed -function Performance.keypressed(key) - if key == config.hudToggleKey then - Performance.toggleHUD() +function Performance:keypressed(key) + if key == self.hudToggleKey then + self:toggleHUD() end end ---- Get configuration ---- @return table config Current configuration -function Performance.getConfig() - return config -end - ---- Set configuration option ---- @param key string Configuration key ---- @param value any Configuration value -function Performance.setConfig(key, value) - config[key] = value -end - ---- Check if performance warnings are enabled ---- @return boolean enabled True if warnings are enabled -function Performance.areWarningsEnabled() - return config.warningsEnabled -end - --- Log a performance warning (only once per warning key) --- @param warningKey string Unique key for this warning type --- @param module string Module name (e.g., "LayoutEngine", "Element") --- @param message string Warning message --- @param details table? Additional details --- @param suggestion string? Optimization suggestion -function Performance.logWarning(warningKey, module, message, details, suggestion) - if not config.warningsEnabled then +function Performance:logWarning(warningKey, module, message, details, suggestion) + if not self.warningsEnabled then return end - -- Only show each warning once per session - if shownWarnings[warningKey] then + if self._shownWarnings[warningKey] then return end - shownWarnings[warningKey] = true + self._shownWarnings[warningKey] = true - -- Limit shownWarnings size to prevent memory leak (keep last 1000 unique warnings) local count = 0 - for _ in pairs(shownWarnings) do + for _ in pairs(self._shownWarnings) do count = count + 1 end if count > 1000 then - -- Reset when limit exceeded (simple approach - could be more sophisticated) - shownWarnings = { [warningKey] = true } + self._shownWarnings = { [warningKey] = true } end - -- Use ErrorHandler if available - local EH = getErrorHandler() - if EH and EH.warn then - EH.warn(module, "PERF_001", message, details or {}, suggestion) + if self._ErrorHandler and self._ErrorHandler.warn then + self._ErrorHandler:warn(module, "PERF_001", message, details or {}, suggestion) else - -- Fallback to print print(string.format("[FlexLove - %s] Performance Warning: %s", module, message)) if suggestion then print(string.format(" Suggestion: %s", suggestion)) @@ -630,50 +414,44 @@ function Performance.logWarning(warningKey, module, message, details, suggestion end end ---- Reset shown warnings (useful for testing or session restart) -function Performance.resetShownWarnings() - shownWarnings = {} -end - --- Track a counter metric (increments per frame) --- @param name string Counter name --- @param value number? Value to add (default: 1) -function Performance.incrementCounter(name, value) - if not config.enabled then +function Performance:incrementCounter(name, value) + if not self.enabled then return end value = value or 1 - if not metrics[name] then - metrics[name] = { + if not self._metrics[name] then + self._metrics[name] = { total = 0, count = 0, min = math.huge, max = 0, average = 0, - frameValue = 0, -- Current frame value + frameValue = 0, lastUsed = love.timer.getTime(), } end - local m = metrics[name] + local m = self._metrics[name] m.frameValue = (m.frameValue or 0) + value m.lastUsed = love.timer.getTime() end --- Reset frame counters (call at end of frame) -function Performance.resetFrameCounters() - if not config.enabled then +function Performance:resetFrameCounters() + if not self.enabled then return end local now = love.timer.getTime() local toRemove = {} - for name, data in pairs(metrics) do + for name, data in pairs(self._metrics) do if data.frameValue then - -- Update statistics only if value is non-zero if data.frameValue > 0 then data.total = data.total + data.frameValue data.count = data.count + 1 @@ -683,89 +461,57 @@ function Performance.resetFrameCounters() data.lastUsed = now end - -- Reset frame value data.frameValue = 0 - -- Mark zero-count metrics for removal (non-core) if data.count == 0 and not CORE_METRICS[name] then table.insert(toRemove, name) end end end - -- Remove zero-value counters for _, name in ipairs(toRemove) do - metrics[name] = nil + self._metrics[name] = nil end end ---- Get current frame counter value ---- @param name string Counter name ---- @return number value Current frame value -function Performance.getFrameCounter(name) - if not config.enabled or not metrics[name] then - return 0 - end - return metrics[name].frameValue or 0 -end - --- ==================== --- Memory Profiling --- ==================== - ---- Enable memory profiling -function Performance.enableMemoryProfiling() - memoryProfiler.enabled = true -end - ---- Disable memory profiling -function Performance.disableMemoryProfiling() - memoryProfiler.enabled = false -end - --- Register a table for memory leak monitoring --- @param name string Friendly name for the table --- @param tableRef table Reference to the table to monitor -function Performance.registerTableForMonitoring(name, tableRef) - memoryProfiler.monitoredTables[name] = tableRef +function Performance:registerTableForMonitoring(name, tableRef) + self._memoryProfiler.monitoredTables[name] = tableRef end ---- Get table size (number of entries) ---- @param tbl table Table to measure ---- @return number count Number of entries -local function getTableSize(tbl) - local count = 0 - for _ in pairs(tbl) do - count = count + 1 - end - return count -end - ---- Sample memory and table sizes -local function sampleMemory() +function Performance:_sampleMemory() local sample = { time = love.timer.getTime(), memory = collectgarbage("count") / 1024, -- MB tableSizes = {}, } + local function getTableSize(tbl) + local count = 0 + for _ in pairs(tbl) do + count = count + 1 + end + return count + end - for name, tableRef in pairs(memoryProfiler.monitoredTables) do + for name, tableRef in pairs(self._memoryProfiler.monitoredTables) do sample.tableSizes[name] = getTableSize(tableRef) end - table.insert(memoryProfiler.samples, sample) + table.insert(self._memoryProfiler.samples, sample) -- Keep only maxSamples - if #memoryProfiler.samples > memoryProfiler.maxSamples then - table.remove(memoryProfiler.samples, 1) + if #self._memoryProfiler.samples > self._memoryProfiler.maxSamples then + table.remove(self._memoryProfiler.samples, 1) end -- Check for memory leaks (consistent growth) - if #memoryProfiler.samples >= 5 then - for name, _ in pairs(memoryProfiler.monitoredTables) do + if #self._memoryProfiler.samples >= 5 then + for name, _ in pairs(self._memoryProfiler.monitoredTables) do local sizes = {} - for i = math.max(1, #memoryProfiler.samples - 4), #memoryProfiler.samples do - table.insert(sizes, memoryProfiler.samples[i].tableSizes[name]) + for i = math.max(1, #self._memoryProfiler.samples - 4), #self._memoryProfiler.samples do + table.insert(sizes, self._memoryProfiler.samples[i].tableSizes[name]) end -- Check if table is consistently growing @@ -778,64 +524,46 @@ local function sampleMemory() end if growing and sizes[#sizes] > sizes[1] * 1.5 then - Performance.addWarning("memory_leak", sizes[#sizes], "warning") - - if not shownWarnings[name] then - -- Route through ErrorHandler for consistent logging - local EH = getErrorHandler() - - if EH and EH.warn then - local message = string.format("Table '%s' growing consistently", name) - EH.warn("Performance", "MEM_001", message, { + self:_addWarning("memory_leak", sizes[#sizes], "warning") + + if not self._shownWarnings[name] then + local message = string.format("Table '%s' growing consistently", name) + if self._ErrorHandler and self._ErrorHandler.warn then + self._ErrorHandler:warn("Performance", "MEM_001", message, { table = name, initialSize = sizes[1], currentSize = sizes[#sizes], growthPercent = math.floor(((sizes[#sizes] / sizes[1]) - 1) * 100), - }, "Check for memory leaks in this table. Review cache eviction policies and ensure objects are properly released.") - else - -- Fallback to direct print - print(string.format("[FlexLove] MEMORY LEAK WARNING: Table '%s' growing consistently (%d -> %d)", name, sizes[1], sizes[#sizes])) + }, "Check for memory leaks. Review cache eviction policies and ensure objects are released.") end - - shownWarnings[name] = true + + self._shownWarnings[name] = true end elseif not growing then - -- Reset warning flag if table stopped growing - shownWarnings[name] = nil + self._shownWarnings[name] = nil end end end end --- Update memory profiling (call from endFrame) -function Performance.updateMemoryProfiling() - if not memoryProfiler.enabled then +function Performance:updateMemoryProfiling() + if not self._memoryProfiler.enabled then return end - memoryProfiler.framesSinceLastSample = memoryProfiler.framesSinceLastSample + 1 + self._memoryProfiler.framesSinceLastSample = self._memoryProfiler.framesSinceLastSample + 1 - if memoryProfiler.framesSinceLastSample >= memoryProfiler.sampleInterval then - sampleMemory() - memoryProfiler.framesSinceLastSample = 0 + if self._memoryProfiler.framesSinceLastSample >= self._memoryProfiler.sampleInterval then + self:_sampleMemory() + self._memoryProfiler.framesSinceLastSample = 0 end end ---- Get memory profiling data ---- @return table profile {samples, monitoredTables} -function Performance.getMemoryProfile() - return { - samples = memoryProfiler.samples, - monitoredTables = {}, - enabled = memoryProfiler.enabled, - } -end - ---- Reset memory profiling data -function Performance.resetMemoryProfile() - memoryProfiler.samples = {} - memoryProfiler.framesSinceLastSample = 0 - shownWarnings = {} +function Performance:resetMemoryProfile() + self._memoryProfiler.samples = {} + self._memoryProfiler.framesSinceLastSample = 0 + self._shownWarnings = {} end return Performance diff --git a/testing/__tests__/easing_test.lua b/testing/__tests__/easing_test.lua index 9bc55cb..f539461 100644 --- a/testing/__tests__/easing_test.lua +++ b/testing/__tests__/easing_test.lua @@ -254,35 +254,6 @@ function TestEasing:testElasticFactory() luaunit.assertAlmostEquals(customElastic(1), 1, 0.01) end --- Test Easing.list() method -function TestEasing:testList() - local list = Easing.list() - luaunit.assertEquals(type(list), "table") - luaunit.assertEquals(#list, 31, "Should have exactly 31 easing functions") - - -- Check that linear is in the list - local hasLinear = false - for _, name in ipairs(list) do - if name == "linear" then - hasLinear = true - break - end - end - luaunit.assertTrue(hasLinear, "List should contain 'linear'") -end - --- Test Easing.get() method -function TestEasing:testGet() - local linear = Easing.get("linear") - luaunit.assertNotNil(linear) - luaunit.assertEquals(type(linear), "function") - luaunit.assertEquals(linear(0.5), 0.5) - - -- Test non-existent easing - local nonExistent = Easing.get("nonExistentEasing") - luaunit.assertNil(nonExistent) -end - -- Test that all InOut easings are symmetric around 0.5 function TestEasing:testInOutSymmetry() local inOutEasings = {