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