Skip to main content

rustmotion_components/
particle.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::{Canvas, MaskFilter, PaintStyle, Rect};
4
5use rustmotion_core::css::CssStyle;
6use rustmotion_core::engine::animator::AnimatedProperties;
7use rustmotion_core::engine::layout_pass::BoxLayout;
8use rustmotion_core::engine::renderer::paint_from_hex;
9use rustmotion_core::schema::TimelineStep;
10use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
11
12#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
13#[serde(rename_all = "snake_case")]
14#[derive(Default)]
15pub enum ParticleType {
16    #[default]
17    Confetti,
18    Snow,
19    Stars,
20    Bubbles,
21    Halo,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
25pub struct SizeRange {
26    pub min: f32,
27    pub max: f32,
28}
29
30impl Default for SizeRange {
31    fn default() -> Self {
32        Self {
33            min: 4.0,
34            max: 12.0,
35        }
36    }
37}
38
39#[derive(Debug, Serialize, Deserialize, JsonSchema)]
40pub struct Particle {
41    pub particle_type: ParticleType,
42    #[serde(default = "default_count")]
43    pub count: u32,
44    #[serde(default)]
45    pub colors: Option<Vec<String>>,
46    #[serde(default = "default_speed")]
47    pub speed: f32,
48    #[serde(default)]
49    pub size_range: SizeRange,
50    #[serde(default = "default_seed")]
51    pub seed: u64,
52    #[serde(flatten)]
53    pub timing: TimingConfig,
54    #[serde(default)]
55    pub style: CssStyle,
56    #[serde(default)]
57    pub timeline: Vec<TimelineStep>,
58    #[serde(default)]
59    pub stagger: Option<f32>,
60}
61
62fn default_count() -> u32 {
63    50
64}
65fn default_speed() -> f32 {
66    1.0
67}
68fn default_seed() -> u64 {
69    42
70}
71
72rustmotion_core::impl_traits!(Particle {
73    Animatable => animation,
74    Timed => timing,
75    Styled => style,
76});
77
78/// Deterministic pseudo-random number generator (splitmix64 for good distribution)
79fn prng(seed: u64) -> (f64, u64) {
80    let mut s = seed.wrapping_add(0x9E3779B97F4A7C15);
81    s = (s ^ (s >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
82    s = (s ^ (s >> 27)).wrapping_mul(0x94D049BB133111EB);
83    s ^= s >> 31;
84    let value = (s as f64) / (u64::MAX as f64);
85    (value.abs(), s)
86}
87
88/// Generate N random values from a seed
89fn rand_values(seed: u64, n: usize) -> Vec<f64> {
90    let mut values = Vec::with_capacity(n);
91    let mut s = seed;
92    for _ in 0..n {
93        let (v, next) = prng(s);
94        values.push(v);
95        s = next;
96    }
97    values
98}
99
100impl Particle {
101    fn default_colors(&self) -> Vec<String> {
102        match self.particle_type {
103            ParticleType::Confetti => vec![
104                "#EF4444", "#3B82F6", "#22C55E", "#F59E0B", "#8B5CF6", "#EC4899", "#06B6D4",
105            ]
106            .into_iter()
107            .map(String::from)
108            .collect(),
109            ParticleType::Snow => vec!["#FFFFFF", "#E0E7FF", "#DBEAFE"]
110                .into_iter()
111                .map(String::from)
112                .collect(),
113            ParticleType::Stars => vec!["#FCD34D", "#FBBF24", "#F59E0B", "#FFFFFF"]
114                .into_iter()
115                .map(String::from)
116                .collect(),
117            ParticleType::Bubbles => vec!["#60A5FA40", "#818CF840", "#A78BFA40"]
118                .into_iter()
119                .map(String::from)
120                .collect(),
121            ParticleType::Halo => vec!["#6366F140", "#8B5CF640", "#EC489940", "#3B82F640"]
122                .into_iter()
123                .map(String::from)
124                .collect(),
125        }
126    }
127}
128
129impl Painter for Particle {
130    fn paint_content(
131        &self,
132        canvas: &Canvas,
133        layout: &BoxLayout,
134        _props: &AnimatedProperties,
135        ctx: &PaintCtx,
136    ) {
137        let w = layout.width;
138        let h = layout.height;
139        let t = ctx.time as f32 * self.speed;
140
141        let colors = self.colors.clone().unwrap_or_else(|| self.default_colors());
142        if colors.is_empty() {
143            return;
144        }
145
146        for i in 0..self.count {
147            let seed = self.seed.wrapping_add(i as u64 * 7919);
148            let rv = rand_values(seed, 6);
149
150            let base_x = rv[0] as f32 * w;
151            let base_y = rv[1] as f32 * h;
152            let size =
153                self.size_range.min + rv[2] as f32 * (self.size_range.max - self.size_range.min);
154            let color_idx = (rv[3] * colors.len() as f64) as usize % colors.len();
155            let phase = rv[4] as f32 * std::f32::consts::TAU;
156            let speed_var = 0.7 + rv[5] as f32 * 0.6;
157
158            let color = &colors[color_idx];
159            let mut paint = paint_from_hex(color);
160            paint.set_anti_alias(true);
161
162            match self.particle_type {
163                ParticleType::Confetti => {
164                    let fall_speed = 80.0 * speed_var;
165                    let x = base_x + (t * 1.5 + phase).sin() * 30.0;
166                    let y = (base_y + t * fall_speed) % h;
167                    let rotation = t * 200.0 * speed_var + phase * 360.0;
168
169                    paint.set_style(PaintStyle::Fill);
170
171                    canvas.save();
172                    canvas.translate((x, y));
173                    canvas.rotate(rotation, None);
174                    let rect = Rect::from_xywh(-size / 2.0, -size / 4.0, size, size / 2.0);
175                    canvas.draw_rect(rect, &paint);
176                    canvas.restore();
177                }
178                ParticleType::Snow => {
179                    let fall_speed = 40.0 * speed_var;
180                    let x = base_x + (t * 0.8 + phase).sin() * 20.0;
181                    let y = (base_y + t * fall_speed) % h;
182
183                    let opacity = 0.5 + 0.5 * (t * 2.0 + phase).sin().abs();
184                    paint.set_style(PaintStyle::Fill);
185                    paint.set_alpha_f(opacity);
186
187                    canvas.draw_circle((x, y), size / 2.0, &paint);
188                }
189                ParticleType::Stars => {
190                    let x = base_x;
191                    let y = base_y;
192                    let twinkle = 0.3 + 0.7 * ((t * 3.0 * speed_var + phase).sin() * 0.5 + 0.5);
193
194                    paint.set_style(PaintStyle::Fill);
195                    paint.set_alpha_f(twinkle);
196
197                    let s = size / 2.0;
198                    let mut path = skia_safe::PathBuilder::new();
199                    path.move_to((x, y - s));
200                    path.line_to((x + s * 0.3, y - s * 0.3));
201                    path.line_to((x + s, y));
202                    path.line_to((x + s * 0.3, y + s * 0.3));
203                    path.line_to((x, y + s));
204                    path.line_to((x - s * 0.3, y + s * 0.3));
205                    path.line_to((x - s, y));
206                    path.line_to((x - s * 0.3, y - s * 0.3));
207                    path.close();
208                    canvas.draw_path(&path.detach(), &paint);
209                }
210                ParticleType::Bubbles => {
211                    let rise_speed = 50.0 * speed_var;
212                    let x = base_x + (t * 0.5 + phase).sin() * 15.0;
213                    let y = h - ((base_y + t * rise_speed) % h);
214                    let size_osc = size * (1.0 + 0.15 * (t * 2.0 + phase).sin());
215
216                    paint.set_style(PaintStyle::Fill);
217                    canvas.draw_circle((x, y), size_osc / 2.0, &paint);
218
219                    let mut highlight = paint_from_hex("#FFFFFF");
220                    highlight.set_style(PaintStyle::Fill);
221                    highlight.set_alpha_f(0.3);
222                    canvas.draw_circle(
223                        (x - size_osc * 0.15, y - size_osc * 0.15),
224                        size_osc * 0.15,
225                        &highlight,
226                    );
227                }
228                ParticleType::Halo => {
229                    let drift_x = (t * 0.3 * speed_var + phase).sin() * w * 0.1;
230                    let drift_y = (t * 0.2 * speed_var + phase * 1.3).cos() * h * 0.1;
231                    let x = base_x + drift_x;
232                    let y = base_y + drift_y;
233
234                    let pulse = 0.4 + 0.6 * ((t * 1.5 * speed_var + phase).sin() * 0.5 + 0.5);
235                    let radius = size * (0.8 + 0.4 * ((t * 0.8 + phase).sin() * 0.5 + 0.5));
236
237                    paint.set_style(PaintStyle::Fill);
238                    paint.set_alpha_f(pulse * 0.6);
239
240                    let blur_sigma = radius * 0.6;
241                    if let Some(mask) =
242                        MaskFilter::blur(skia_safe::BlurStyle::Normal, blur_sigma, false)
243                    {
244                        paint.set_mask_filter(mask);
245                    }
246
247                    canvas.draw_circle((x, y), radius, &paint);
248                }
249            }
250        }
251    }
252}