//! Animation System //! //! Provides easing functions and animation management for smooth transitions. //! Supports various easing curves and animation lifecycle. const std = @import("std"); /// Easing function type pub const EasingFn = *const fn (f32) f32; /// Standard easing functions pub const Easing = struct { /// Linear interpolation (no easing) pub fn linear(t: f32) f32 { return t; } /// Quadratic ease-in pub fn easeInQuad(t: f32) f32 { return t * t; } /// Quadratic ease-out pub fn easeOutQuad(t: f32) f32 { return t * (2 - t); } /// Quadratic ease-in-out pub fn easeInOutQuad(t: f32) f32 { if (t < 0.5) { return 2 * t * t; } else { return -1 + (4 - 2 * t) * t; } } /// Cubic ease-in pub fn easeInCubic(t: f32) f32 { return t * t * t; } /// Cubic ease-out pub fn easeOutCubic(t: f32) f32 { const t1 = t - 1; return t1 * t1 * t1 + 1; } /// Cubic ease-in-out pub fn easeInOutCubic(t: f32) f32 { if (t < 0.5) { return 4 * t * t * t; } else { const t1 = 2 * t - 2; return 0.5 * t1 * t1 * t1 + 1; } } /// Quartic ease-in pub fn easeInQuart(t: f32) f32 { return t * t * t * t; } /// Quartic ease-out pub fn easeOutQuart(t: f32) f32 { const t1 = t - 1; return 1 - t1 * t1 * t1 * t1; } /// Quartic ease-in-out pub fn easeInOutQuart(t: f32) f32 { if (t < 0.5) { return 8 * t * t * t * t; } else { const t1 = t - 1; return 1 - 8 * t1 * t1 * t1 * t1; } } /// Sine ease-in pub fn easeInSine(t: f32) f32 { return 1 - @cos(t * std.math.pi / 2); } /// Sine ease-out pub fn easeOutSine(t: f32) f32 { return @sin(t * std.math.pi / 2); } /// Sine ease-in-out pub fn easeInOutSine(t: f32) f32 { return 0.5 * (1 - @cos(std.math.pi * t)); } /// Exponential ease-in pub fn easeInExpo(t: f32) f32 { if (t == 0) return 0; return std.math.pow(f32, 2, 10 * (t - 1)); } /// Exponential ease-out pub fn easeOutExpo(t: f32) f32 { if (t == 1) return 1; return 1 - std.math.pow(f32, 2, -10 * t); } /// Exponential ease-in-out pub fn easeInOutExpo(t: f32) f32 { if (t == 0) return 0; if (t == 1) return 1; if (t < 0.5) { return 0.5 * std.math.pow(f32, 2, 20 * t - 10); } else { return 1 - 0.5 * std.math.pow(f32, 2, -20 * t + 10); } } /// Elastic ease-in pub fn easeInElastic(t: f32) f32 { if (t == 0) return 0; if (t == 1) return 1; const p: f32 = 0.3; return -std.math.pow(f32, 2, 10 * (t - 1)) * @sin((t - 1 - p / 4) * 2 * std.math.pi / p); } /// Elastic ease-out pub fn easeOutElastic(t: f32) f32 { if (t == 0) return 0; if (t == 1) return 1; const p: f32 = 0.3; return std.math.pow(f32, 2, -10 * t) * @sin((t - p / 4) * 2 * std.math.pi / p) + 1; } /// Bounce ease-out pub fn easeOutBounce(t: f32) f32 { const n1: f32 = 7.5625; const d1: f32 = 2.75; if (t < 1 / d1) { return n1 * t * t; } else if (t < 2 / d1) { const t1 = t - 1.5 / d1; return n1 * t1 * t1 + 0.75; } else if (t < 2.5 / d1) { const t1 = t - 2.25 / d1; return n1 * t1 * t1 + 0.9375; } else { const t1 = t - 2.625 / d1; return n1 * t1 * t1 + 0.984375; } } /// Bounce ease-in pub fn easeInBounce(t: f32) f32 { return 1 - easeOutBounce(1 - t); } /// Bounce ease-in-out pub fn easeInOutBounce(t: f32) f32 { if (t < 0.5) { return 0.5 * easeInBounce(t * 2); } else { return 0.5 * easeOutBounce(t * 2 - 1) + 0.5; } } /// Back ease-in (overshoots) pub fn easeInBack(t: f32) f32 { const s: f32 = 1.70158; return t * t * ((s + 1) * t - s); } /// Back ease-out (overshoots) pub fn easeOutBack(t: f32) f32 { const s: f32 = 1.70158; const t1 = t - 1; return t1 * t1 * ((s + 1) * t1 + s) + 1; } /// Back ease-in-out (overshoots) pub fn easeInOutBack(t: f32) f32 { const s: f32 = 1.70158 * 1.525; if (t < 0.5) { return 0.5 * (4 * t * t * ((s + 1) * 2 * t - s)); } else { const t1 = 2 * t - 2; return 0.5 * (t1 * t1 * ((s + 1) * t1 + s) + 2); } } }; /// An animation from start to end value pub const Animation = struct { /// Starting value start_value: f32, /// Target value end_value: f32, /// Duration in milliseconds duration_ms: u32, /// Easing function easing: EasingFn = Easing.linear, /// Start timestamp (ms) start_time: i64 = 0, /// Is animation running running: bool = false, /// Loop animation looping: bool = false, /// Ping-pong (reverse on loop) pingpong: bool = false, /// Current direction (for pingpong) reverse: bool = false, const Self = @This(); /// Create a new animation pub fn create(start_val: f32, end_val: f32, duration_ms: u32, easing_fn: EasingFn) Self { return .{ .start_value = start_val, .end_value = end_val, .duration_ms = duration_ms, .easing = easing_fn, }; } /// Start the animation pub fn start(self: *Self, current_time_ms: i64) void { self.start_time = current_time_ms; self.running = true; self.reverse = false; } /// Stop the animation pub fn stop(self: *Self) void { self.running = false; } /// Get current animated value pub fn getValue(self: Self, current_time_ms: i64) f32 { if (!self.running) { return self.start_value; } const elapsed = current_time_ms - self.start_time; if (elapsed < 0) return self.start_value; var t = @as(f32, @floatFromInt(elapsed)) / @as(f32, @floatFromInt(self.duration_ms)); if (t >= 1.0) { if (self.looping) { if (self.pingpong) { // Would handle loop logic here t = 1.0; } else { t = @mod(t, 1.0); } } else { t = 1.0; } } const eased = self.easing(t); if (self.reverse) { return self.end_value + (self.start_value - self.end_value) * eased; } else { return self.start_value + (self.end_value - self.start_value) * eased; } } /// Check if animation is complete pub fn isComplete(self: Self, current_time_ms: i64) bool { if (!self.running) return true; if (self.looping) return false; const elapsed = current_time_ms - self.start_time; return elapsed >= @as(i64, @intCast(self.duration_ms)); } /// Get progress (0.0 - 1.0) pub fn getProgress(self: Self, current_time_ms: i64) f32 { if (!self.running) return 0; const elapsed = current_time_ms - self.start_time; if (elapsed < 0) return 0; const t = @as(f32, @floatFromInt(elapsed)) / @as(f32, @floatFromInt(self.duration_ms)); return @min(1.0, @max(0.0, t)); } }; /// Maximum concurrent animations const MAX_ANIMATIONS = 128; /// Animation manager for tracking multiple animations pub const AnimationManager = struct { animations: [MAX_ANIMATIONS]struct { id: u64, anim: Animation, active: bool, } = undefined, count: usize = 0, const Self = @This(); /// Initialize manager pub fn init() Self { var manager = Self{}; for (&manager.animations) |*slot| { slot.active = false; } return manager; } /// Start a new animation pub fn startAnimation(self: *Self, id: u64, anim: Animation, current_time_ms: i64) void { // Check if already exists var i: usize = 0; while (i < self.count) : (i += 1) { if (self.animations[i].active and self.animations[i].id == id) { self.animations[i].anim = anim; self.animations[i].anim.start(current_time_ms); return; } } // Add new if (self.count < MAX_ANIMATIONS) { self.animations[self.count] = .{ .id = id, .anim = anim, .active = true, }; self.animations[self.count].anim.start(current_time_ms); self.count += 1; } } /// Stop an animation pub fn stopAnimation(self: *Self, id: u64) void { var i: usize = 0; while (i < self.count) : (i += 1) { if (self.animations[i].active and self.animations[i].id == id) { self.animations[i].anim.stop(); self.animations[i].active = false; return; } } } /// Get animation value pub fn getValue(self: Self, id: u64, current_time_ms: i64) ?f32 { var i: usize = 0; while (i < self.count) : (i += 1) { const slot = self.animations[i]; if (slot.active and slot.id == id) { return slot.anim.getValue(current_time_ms); } } return null; } /// Check if animation exists and is running pub fn isRunning(self: Self, id: u64) bool { var i: usize = 0; while (i < self.count) : (i += 1) { const slot = self.animations[i]; if (slot.active and slot.id == id) { return slot.anim.running; } } return false; } /// Update animations, removing completed ones pub fn update(self: *Self, current_time_ms: i64) void { var i: usize = 0; while (i < self.count) : (i += 1) { if (self.animations[i].active and self.animations[i].anim.isComplete(current_time_ms) and !self.animations[i].anim.looping) { self.animations[i].active = false; } } // Compact array self.compact(); } /// Remove inactive animations fn compact(self: *Self) void { var write_idx: usize = 0; var i: usize = 0; while (i < self.count) : (i += 1) { const slot = self.animations[i]; if (slot.active) { self.animations[write_idx] = slot; write_idx += 1; } } self.count = write_idx; } /// Get count of active animations pub fn activeCount(self: Self) usize { var cnt: usize = 0; var i: usize = 0; while (i < self.count) : (i += 1) { if (self.animations[i].active) cnt += 1; } return cnt; } }; /// Interpolate between two values pub fn lerp(a: f32, b: f32, t: f32) f32 { return a + (b - a) * t; } /// Interpolate between two integers pub fn lerpInt(a: i32, b: i32, t: f32) i32 { return a + @as(i32, @intFromFloat(@as(f32, @floatFromInt(b - a)) * t)); } // ============================================================================= // Tests // ============================================================================= test "Easing linear" { try std.testing.expectEqual(@as(f32, 0.0), Easing.linear(0.0)); try std.testing.expectEqual(@as(f32, 0.5), Easing.linear(0.5)); try std.testing.expectEqual(@as(f32, 1.0), Easing.linear(1.0)); } test "Easing quadratic" { try std.testing.expectEqual(@as(f32, 0.0), Easing.easeInQuad(0.0)); try std.testing.expectEqual(@as(f32, 1.0), Easing.easeInQuad(1.0)); try std.testing.expect(Easing.easeInQuad(0.5) < 0.5); // Slower start try std.testing.expectEqual(@as(f32, 0.0), Easing.easeOutQuad(0.0)); try std.testing.expectEqual(@as(f32, 1.0), Easing.easeOutQuad(1.0)); try std.testing.expect(Easing.easeOutQuad(0.5) > 0.5); // Faster start } test "Animation create and getValue" { var anim = Animation.create(0, 100, 1000, Easing.linear); anim.start(0); try std.testing.expectEqual(@as(f32, 0), anim.getValue(0)); try std.testing.expectEqual(@as(f32, 50), anim.getValue(500)); try std.testing.expectEqual(@as(f32, 100), anim.getValue(1000)); try std.testing.expectEqual(@as(f32, 100), anim.getValue(1500)); // Clamped } test "Animation isComplete" { var anim = Animation.create(0, 100, 1000, Easing.linear); anim.start(0); try std.testing.expect(!anim.isComplete(500)); try std.testing.expect(anim.isComplete(1000)); try std.testing.expect(anim.isComplete(1500)); } test "AnimationManager basic" { var manager = AnimationManager.init(); manager.startAnimation(1, Animation.create(0, 100, 1000, Easing.linear), 0); try std.testing.expect(manager.isRunning(1)); try std.testing.expectEqual(@as(?f32, 50), manager.getValue(1, 500)); manager.stopAnimation(1); try std.testing.expect(!manager.isRunning(1)); } test "AnimationManager update" { var manager = AnimationManager.init(); manager.startAnimation(1, Animation.create(0, 100, 100, Easing.linear), 0); manager.startAnimation(2, Animation.create(0, 100, 200, Easing.linear), 0); try std.testing.expectEqual(@as(usize, 2), manager.count); manager.update(150); // Animation 1 should be removed (complete), 2 still active try std.testing.expectEqual(@as(usize, 1), manager.activeCount()); } test "lerp" { try std.testing.expectEqual(@as(f32, 0.0), lerp(0, 100, 0)); try std.testing.expectEqual(@as(f32, 50.0), lerp(0, 100, 0.5)); try std.testing.expectEqual(@as(f32, 100.0), lerp(0, 100, 1.0)); } // ============================================================================= // Spring Physics (Gio parity) // ============================================================================= /// Spring animation configuration pub const SpringConfig = struct { /// Spring stiffness (higher = faster) stiffness: f32 = 100.0, /// Damping factor (higher = less oscillation) damping: f32 = 10.0, /// Mass (higher = more momentum) mass: f32 = 1.0, }; /// Spring animation state for physics-based animations pub const Spring = struct { /// Current position position: f32 = 0.0, /// Current velocity velocity: f32 = 0.0, /// Target position target: f32 = 0.0, /// Configuration config: SpringConfig = .{}, /// Threshold for considering settled threshold: f32 = 0.001, const Self = @This(); /// Create a spring from initial to target pub fn create(initial: f32, target_val: f32, config: SpringConfig) Self { return .{ .position = initial, .target = target_val, .config = config, }; } /// Update spring physics by delta time (seconds) pub fn update(self: *Self, dt: f32) void { const displacement = self.position - self.target; const spring_force = -self.config.stiffness * displacement; const damping_force = -self.config.damping * self.velocity; const acceleration = (spring_force + damping_force) / self.config.mass; self.velocity += acceleration * dt; self.position += self.velocity * dt; } /// Check if spring has settled at target pub fn isSettled(self: *const Self) bool { const displacement = @abs(self.position - self.target); return displacement < self.threshold and @abs(self.velocity) < self.threshold; } /// Set new target position pub fn setTarget(self: *Self, new_target: f32) void { self.target = new_target; } /// Snap to target immediately pub fn snap(self: *Self) void { self.position = self.target; self.velocity = 0; } /// Get current value pub fn getValue(self: *const Self) f32 { return self.position; } }; test "Spring physics basic" { var spring = Spring.create(0.0, 100.0, .{ .stiffness = 100.0, .damping = 10.0, }); // Simulate several frames var i: usize = 0; while (i < 200) : (i += 1) { spring.update(0.016); // ~60fps } // Should be close to target and settled try std.testing.expect(@abs(spring.position - spring.target) < 1.0); try std.testing.expect(spring.isSettled()); } test "Spring snap" { var spring = Spring.create(0.0, 100.0, .{}); spring.snap(); try std.testing.expectEqual(@as(f32, 100.0), spring.position); try std.testing.expectEqual(@as(f32, 0.0), spring.velocity); }