use std::f32::consts::PI;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Easing {
Linear,
EaseIn,
EaseOut,
EaseInOut,
}
impl Easing {
pub fn apply(&self, t: f32) -> f32 {
match self {
Easing::Linear => t,
Easing::EaseIn => t * t,
Easing::EaseOut => t * (2.0 - t),
Easing::EaseInOut => {
if t < 0.5 {
2.0 * t * t
} else {
-1.0 + (4.0 - 2.0 * t) * t
}
}
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Particle {
pub origin_x: f32,
pub origin_y: f32,
pub angle: f32,
pub start_time: f64,
}
impl Particle {
pub fn calculate(
&self,
current_time: f64,
duration: f64,
easing: Easing,
radius: f32,
size: f32,
) -> Option<ParticleRender> {
let elapsed = current_time - self.start_time;
if elapsed >= duration {
return None;
}
let progress = (elapsed / duration) as f32;
let eased = easing.apply(progress);
let distance = eased * radius;
let line_length = size * (1.0 - eased);
let x1 = self.origin_x + distance * self.angle.cos();
let y1 = self.origin_y + distance * self.angle.sin();
let x2 = self.origin_x + (distance + line_length) * self.angle.cos();
let y2 = self.origin_y + (distance + line_length) * self.angle.sin();
Some(ParticleRender {
x1,
y1,
x2,
y2,
opacity: 1.0 - progress,
})
}
}
#[derive(Debug, Clone, Copy)]
pub struct ParticleRender {
pub x1: f32,
pub y1: f32,
pub x2: f32,
pub y2: f32,
pub opacity: f32,
}
#[derive(Debug, Clone)]
#[derive(Default)]
pub struct ClickSparkState {
pub particles: Vec<Particle>,
}
pub struct ClickSpark {
pub count: usize,
pub radius: f32,
pub size: f32,
pub duration: f64,
pub easing: Easing,
}
impl Default for ClickSpark {
fn default() -> Self {
Self {
count: 8,
radius: 15.0,
size: 10.0,
duration: 0.4,
easing: Easing::EaseOut,
}
}
}
impl ClickSpark {
pub fn new() -> Self {
Self::default()
}
pub fn with_count(mut self, count: usize) -> Self {
self.count = count;
self
}
pub fn with_radius(mut self, radius: f32) -> Self {
self.radius = radius;
self
}
pub fn with_size(mut self, size: f32) -> Self {
self.size = size;
self
}
pub fn with_duration(mut self, duration: f64) -> Self {
self.duration = duration;
self
}
pub fn with_easing(mut self, easing: Easing) -> Self {
self.easing = easing;
self
}
pub fn handle_click(&self, state: &mut ClickSparkState, x: f32, y: f32, current_time: f64) {
let new_particles: Vec<Particle> = (0..self.count)
.map(|i| {
let angle = (2.0 * PI * i as f32) / self.count as f32;
Particle {
origin_x: x,
origin_y: y,
angle,
start_time: current_time,
}
})
.collect();
state.particles.extend(new_particles);
}
pub fn update(&self, state: &mut ClickSparkState, current_time: f64) -> Vec<ParticleRender> {
let mut rendered = Vec::new();
state.particles.retain(|particle| {
if let Some(render) =
particle.calculate(current_time, self.duration, self.easing, self.radius, self.size)
{
rendered.push(render);
true
} else {
false
}
});
rendered
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_easing_functions() {
assert_eq!(Easing::Linear.apply(0.5), 0.5);
assert_eq!(Easing::EaseIn.apply(0.5), 0.25);
let ease_out = Easing::EaseOut.apply(0.5);
assert!((ease_out - 0.75).abs() < 0.001);
assert_eq!(Easing::Linear.apply(0.0), 0.0);
assert_eq!(Easing::Linear.apply(1.0), 1.0);
}
#[test]
fn test_click_spark_creation() {
let spark = ClickSpark::new().with_count(4);
let mut state = ClickSparkState::default();
spark.handle_click(&mut state, 100.0, 100.0, 0.0);
assert_eq!(state.particles.len(), 4);
let expected_angles = [0.0, PI / 2.0, PI, 3.0 * PI / 2.0];
for (i, particle) in state.particles.iter().enumerate() {
assert!((particle.angle - expected_angles[i]).abs() < 0.001);
assert_eq!(particle.origin_x, 100.0);
assert_eq!(particle.origin_y, 100.0);
assert_eq!(particle.start_time, 0.0);
}
}
#[test]
fn test_particle_calculation() {
let particle = Particle {
origin_x: 0.0,
origin_y: 0.0,
angle: 0.0, start_time: 0.0,
};
let render = particle.calculate(0.2, 0.4, Easing::EaseOut, 100.0, 20.0);
assert!(render.is_some());
let render = render.unwrap();
assert!((render.x1 - 75.0).abs() < 0.1);
assert_eq!(render.y1, 0.0);
assert!((render.x2 - 80.0).abs() < 0.1);
assert_eq!(render.y2, 0.0);
}
#[test]
fn test_particle_expiration() {
let particle = Particle {
origin_x: 0.0,
origin_y: 0.0,
angle: 0.0,
start_time: 0.0,
};
let render = particle.calculate(0.5, 0.4, Easing::Linear, 100.0, 20.0);
assert!(render.is_none());
}
#[test]
fn test_update_removes_expired() {
let spark = ClickSpark::new().with_count(2).with_duration(0.1);
let mut state = ClickSparkState::default();
spark.handle_click(&mut state, 0.0, 0.0, 0.0);
assert_eq!(state.particles.len(), 2);
let rendered = spark.update(&mut state, 0.05);
assert_eq!(rendered.len(), 2);
assert_eq!(state.particles.len(), 2);
let rendered = spark.update(&mut state, 0.2);
assert_eq!(rendered.len(), 0);
assert_eq!(state.particles.len(), 0);
}
}