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}