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
78fn 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
88fn 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}