1#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
11pub enum ParticleLocomotion {
12 #[default]
14 Idle,
15 Moving,
17 Jumping,
19 Landing,
21 Sprinting,
23}
24
25impl ParticleLocomotion {
26 pub fn shape_params(self) -> ParticleShapeParams {
29 match self {
30 Self::Idle => ParticleShapeParams {
31 stretch: [1.0, 1.0, 1.0],
32 radius_scale: 1.0,
33 particle_scale: 0.08,
34 noise_amplitude: 0.06,
35 },
36 Self::Moving => ParticleShapeParams {
37 stretch: [1.0, 0.9, 1.3],
38 radius_scale: 1.2,
39 particle_scale: 0.07,
40 noise_amplitude: 0.10,
41 },
42 Self::Jumping => ParticleShapeParams {
43 stretch: [1.2, 1.5, 1.2],
44 radius_scale: 1.5,
45 particle_scale: 0.06,
46 noise_amplitude: 0.14,
47 },
48 Self::Landing => ParticleShapeParams {
49 stretch: [1.5, 0.5, 1.5],
50 radius_scale: 1.3,
51 particle_scale: 0.09,
52 noise_amplitude: 0.08,
53 },
54 Self::Sprinting => ParticleShapeParams {
55 stretch: [0.8, 0.85, 1.8],
56 radius_scale: 1.4,
57 particle_scale: 0.06,
58 noise_amplitude: 0.12,
59 },
60 }
61 }
62
63 pub fn label(self) -> &'static str {
64 match self {
65 Self::Idle => "Idle",
66 Self::Moving => "Moving",
67 Self::Jumping => "Jumping",
68 Self::Landing => "Landing",
69 Self::Sprinting => "Sprinting",
70 }
71 }
72}
73
74#[derive(Debug, Clone, Copy)]
76pub struct ParticleShapeParams {
77 pub stretch: [f32; 3],
79 pub radius_scale: f32,
81 pub particle_scale: f32,
83 pub noise_amplitude: f32,
85}
86
87#[derive(Clone)]
102pub struct WaveFormationState {
103 pub phase_time: f32,
105 pub idle_time: f32,
107 pub fibonacci_blend: f32,
109 pub amplitude: f32,
111 pub frequency: f32,
113 pub wavelength: f32,
115 pub heading: [f32; 2],
117 pub offsets: Vec<[f32; 3]>,
119}
120
121impl WaveFormationState {
122 pub fn new(particle_count: u32) -> Self {
123 Self {
124 phase_time: 0.0,
125 idle_time: 0.0,
126 fibonacci_blend: 0.0,
127 amplitude: 0.3,
128 frequency: 2.0,
129 wavelength: 1.5,
130 heading: [0.0, 1.0],
131 offsets: vec![[0.0; 3]; particle_count as usize],
132 }
133 }
134
135 pub fn update(&mut self, speed: f32, heading: [f32; 2], yaw: f32, dt: f32, particle_count: u32, radius: f32) {
140 self.phase_time += dt;
141 let moving = speed > 0.5;
142
143 if moving {
144 self.idle_time = 0.0;
146 self.amplitude = (speed * 0.06).clamp(0.1, 0.5);
149 self.heading = if heading[0].abs() + heading[1].abs() > 0.01 {
150 heading
151 } else {
152 [yaw.sin(), yaw.cos()]
153 };
154 self.fibonacci_blend *= (-6.0 * dt).exp(); } else {
157 self.idle_time += dt;
159 self.fibonacci_blend = 1.0 - (-2.0 * self.idle_time).exp();
162 self.amplitude *= (-3.0 * dt).exp();
164 }
165
166 let n = particle_count as usize;
168 if self.offsets.len() != n {
169 self.offsets.resize(n, [0.0; 3]);
170 }
171
172 let k = std::f32::consts::TAU / self.wavelength; let omega = std::f32::consts::TAU * self.frequency; let t = self.phase_time;
175 let a = self.amplitude;
176 let golden_angle = std::f32::consts::TAU / (((1.0 + 5.0f32.sqrt()) / 2.0) * ((1.0 + 5.0f32.sqrt()) / 2.0));
177 let fib_blend = self.fibonacci_blend.clamp(0.0, 1.0);
178
179 for i in 0..n {
180 let frac = (i as f32 + 0.5) / n as f32;
181
182 let grid_side = (n as f32).sqrt().ceil() as usize;
186 let gx = (i % grid_side) as f32 / grid_side as f32 - 0.5;
187 let gz = (i / grid_side) as f32 / grid_side as f32 - 0.5;
188
189 let hx = self.heading[0];
191 let hz = self.heading[1];
192 let world_x = gx * hz - gz * hx; let world_z = gx * hx + gz * hz; let wave_y = a * (k * world_z * radius * 2.0 - omega * t).sin() * (k * world_x * radius * 0.5).cos();
198
199 let wave_pos = [world_x * radius * 2.0, wave_y, world_z * radius * 2.0];
200
201 let fib_r = frac.sqrt() * radius * 1.2;
204 let fib_theta = i as f32 * golden_angle + t * 0.3; let fib_pos = [
206 fib_r * fib_theta.cos(),
207 a * 0.1 * (fib_theta * 3.0 + t).sin(), fib_r * fib_theta.sin(),
209 ];
210
211 self.offsets[i] = [
213 wave_pos[0] * (1.0 - fib_blend) + fib_pos[0] * fib_blend,
214 wave_pos[1] * (1.0 - fib_blend) + fib_pos[1] * fib_blend,
215 wave_pos[2] * (1.0 - fib_blend) + fib_pos[2] * fib_blend,
216 ];
217 }
218 }
219}
220
221pub struct ParticleController {
224 pub position: [f32; 3],
226 pub previous_position: [f32; 3],
228 pub velocity: [f32; 3],
230 pub move_speed: f32,
232 pub sprint_multiplier: f32,
234 pub locomotion: ParticleLocomotion,
236 pub gravity: f32,
238 pub grounded: bool,
240 pub landing_timer: f32,
242 pub facing_yaw: f32,
244 pub cloud_radius: f32,
246 pub particle_count: u32,
248 pub base_color: [f32; 4],
250 pub movement_force_direction: [f32; 3],
252 pub movement_force_magnitude: f32,
254 pub rheology_blend: f32,
256 pub rheology_target: f32,
258 pub wave_formation: WaveFormationState,
260}
261
262impl Default for ParticleController {
263 fn default() -> Self {
264 Self {
265 position: [0.0, 1.0, 0.0],
266 previous_position: [0.0, 1.0, 0.0],
267 velocity: [0.0; 3],
268 move_speed: 5.0,
269 sprint_multiplier: 2.0,
270 locomotion: ParticleLocomotion::Idle,
271 gravity: 1.2,
272 grounded: true,
273 landing_timer: 0.0,
274 facing_yaw: 0.0,
275 cloud_radius: 0.8,
276 particle_count: 256,
277 base_color: [0.4, 0.6, 1.0, 0.85],
278 movement_force_direction: [0.0; 3],
279 movement_force_magnitude: 0.0,
280 rheology_blend: 0.15,
281 rheology_target: 0.15,
282 wave_formation: WaveFormationState::new(256),
283 }
284 }
285}
286
287impl ParticleController {
288 pub fn new(position: [f32; 3], particle_count: u32) -> Self {
289 Self {
290 position,
291 particle_count,
292 wave_formation: WaveFormationState::new(particle_count),
293 ..Default::default()
294 }
295 }
296
297 pub fn update(
300 &mut self,
301 forward: bool,
302 back: bool,
303 left: bool,
304 right: bool,
305 jump: bool,
306 sprint: bool,
307 camera_yaw: f32,
308 dt: f32,
309 ) -> bool {
310 self.previous_position = self.position;
312
313 let mut dir_fwd = 0.0f32;
315 let mut dir_right = 0.0f32;
316 if forward {
317 dir_fwd += 1.0;
318 }
319 if back {
320 dir_fwd -= 1.0;
321 }
322 if left {
323 dir_right -= 1.0;
324 }
325 if right {
326 dir_right += 1.0;
327 }
328
329 let len = (dir_fwd * dir_fwd + dir_right * dir_right).sqrt();
330 let moving = len > 0.001;
331
332 if moving {
333 dir_fwd /= len;
334 dir_right /= len;
335 }
336
337 let cos_yaw = camera_yaw.cos();
339 let sin_yaw = camera_yaw.sin();
340 let world_x = dir_fwd * (-cos_yaw) + dir_right * sin_yaw;
341 let world_z = dir_fwd * (-sin_yaw) + dir_right * (-cos_yaw);
342
343 let speed = if sprint {
345 self.move_speed * self.sprint_multiplier
346 } else {
347 self.move_speed
348 };
349 self.velocity[0] = if moving { world_x * speed } else { 0.0 };
350 self.velocity[2] = if moving { world_z * speed } else { 0.0 };
351
352 if !self.grounded {
354 self.velocity[1] -= self.gravity * dt;
355 } else if jump {
356 self.velocity[1] = 3.5;
357 self.grounded = false;
358 }
359
360 self.position[0] += self.velocity[0] * dt;
362 self.position[1] += self.velocity[1] * dt;
363 self.position[2] += self.velocity[2] * dt;
364
365 let was_airborne = !self.grounded;
367 if self.position[1] <= 0.5 {
368 self.position[1] = 0.5;
369 self.velocity[1] = 0.0;
370 self.grounded = true;
371 if was_airborne {
372 self.landing_timer = 0.3;
373 }
374 }
375
376 if moving {
378 self.facing_yaw = world_z.atan2(world_x);
379 }
380
381 self.landing_timer = (self.landing_timer - dt).max(0.0);
383 self.locomotion = if self.landing_timer > 0.0 {
384 ParticleLocomotion::Landing
385 } else if !self.grounded {
386 ParticleLocomotion::Jumping
387 } else if sprint && moving {
388 ParticleLocomotion::Sprinting
389 } else if moving {
390 ParticleLocomotion::Moving
391 } else {
392 ParticleLocomotion::Idle
393 };
394
395 if moving {
397 self.movement_force_direction = [world_x, 0.0, world_z];
398 self.movement_force_magnitude = speed;
399 } else {
400 self.movement_force_direction = [0.0; 3];
401 self.movement_force_magnitude = 0.0;
402 }
403
404 self.rheology_target = match self.locomotion {
406 ParticleLocomotion::Idle => 0.15,
407 ParticleLocomotion::Moving => 0.5,
408 ParticleLocomotion::Sprinting => 0.7,
409 ParticleLocomotion::Jumping => 0.1,
410 ParticleLocomotion::Landing => 0.6,
411 };
412 let blend_rate = 1.0 - (-8.0 * dt).exp();
413 self.rheology_blend += (self.rheology_target - self.rheology_blend) * blend_rate;
414
415 let speed_mag = (self.velocity[0] * self.velocity[0] + self.velocity[2] * self.velocity[2]).sqrt();
417 let heading = if speed_mag > 0.01 {
418 [self.velocity[0] / speed_mag, self.velocity[2] / speed_mag]
419 } else {
420 [0.0, 0.0]
421 };
422 self.wave_formation.update(
423 speed_mag,
424 heading,
425 self.facing_yaw,
426 dt,
427 self.particle_count,
428 self.cloud_radius,
429 );
430
431 moving
432 }
433
434 pub fn wave_offsets(&self) -> &[[f32; 3]] {
437 &self.wave_formation.offsets
438 }
439
440 pub fn shape_params(&self) -> ParticleShapeParams {
442 self.locomotion.shape_params()
443 }
444}
445
446pub fn particle_sphere_offsets(count: u32) -> Vec<[f32; 3]> {
450 let golden_ratio = (1.0 + 5.0f32.sqrt()) / 2.0;
451 let angle_increment = std::f32::consts::TAU * golden_ratio;
452 let mut offsets = Vec::with_capacity(count as usize);
453
454 for i in 0..count {
455 let t = (i as f32 + 0.5) / count as f32;
456 let phi = (1.0 - 2.0 * t).acos();
457 let theta = angle_increment * i as f32;
458
459 let x = phi.sin() * theta.cos();
460 let y = phi.cos();
461 let z = phi.sin() * theta.sin();
462 offsets.push([x, y, z]);
463 }
464
465 offsets
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471
472 #[test]
473 fn golden_spiral_produces_correct_count() {
474 let pts = particle_sphere_offsets(128);
475 assert_eq!(pts.len(), 128);
476 for p in &pts {
478 let len = (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt();
479 assert!((len - 1.0).abs() < 0.01, "point not on unit sphere: len={len}");
480 }
481 }
482
483 #[test]
484 fn particle_locomotion_transitions() {
485 let mut ctrl = ParticleController::default();
486 assert_eq!(ctrl.locomotion, ParticleLocomotion::Idle);
487
488 ctrl.update(true, false, false, false, false, false, 0.0, 0.016);
490 assert_eq!(ctrl.locomotion, ParticleLocomotion::Moving);
491
492 ctrl.update(true, false, false, false, false, true, 0.0, 0.016);
494 assert_eq!(ctrl.locomotion, ParticleLocomotion::Sprinting);
495
496 ctrl.update(false, false, false, false, false, false, 0.0, 0.016);
498 assert_eq!(ctrl.locomotion, ParticleLocomotion::Idle);
499 }
500
501 #[test]
502 fn particle_jump_and_land() {
503 let mut ctrl = ParticleController::default();
504 ctrl.position = [0.0, 0.5, 0.0];
505
506 ctrl.update(false, false, false, false, true, false, 0.0, 0.016);
508 assert_eq!(ctrl.locomotion, ParticleLocomotion::Jumping);
509 assert!(!ctrl.grounded);
510
511 for _ in 0..400 {
513 ctrl.update(false, false, false, false, false, false, 0.0, 0.016);
514 }
515 assert!(ctrl.grounded);
516 assert!(ctrl.locomotion == ParticleLocomotion::Idle || ctrl.locomotion == ParticleLocomotion::Landing);
518 }
519
520 #[test]
521 fn shape_params_vary_by_state() {
522 let idle = ParticleLocomotion::Idle.shape_params();
523 let moving = ParticleLocomotion::Moving.shape_params();
524 assert!(idle.radius_scale < moving.radius_scale);
525 assert!(idle.noise_amplitude < moving.noise_amplitude);
526 }
527
528 #[test]
531 fn particle_controller_previous_position_stored() {
532 let mut ctrl = ParticleController::new([3.0, 1.0, -2.0], 128);
533 let pos_before = ctrl.position;
534
535 ctrl.update(true, false, false, false, false, false, 0.0, 0.016);
537 assert_eq!(
538 ctrl.previous_position, pos_before,
539 "previous_position should equal position before update"
540 );
541 assert_ne!(ctrl.position, pos_before, "position should change when moving forward");
543 }
544
545 #[test]
546 fn particle_controller_force_direction_when_moving() {
547 let mut ctrl = ParticleController::default();
548 ctrl.update(true, false, false, false, false, false, 0.0, 0.016);
549
550 let dir = ctrl.movement_force_direction;
551 let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
552 assert!(
553 len > 0.001,
554 "movement_force_direction should be nonzero when moving, got length {}",
555 len
556 );
557 }
558
559 #[test]
560 fn particle_controller_force_direction_when_idle() {
561 let mut ctrl = ParticleController::default();
562 ctrl.update(false, false, false, false, false, false, 0.0, 0.016);
564
565 assert_eq!(
566 ctrl.movement_force_magnitude, 0.0,
567 "movement_force_magnitude should be 0 when idle"
568 );
569 assert_eq!(
570 ctrl.movement_force_direction, [0.0; 3],
571 "movement_force_direction should be zero when idle"
572 );
573 }
574
575 #[test]
576 fn particle_controller_rheology_idle_target() {
577 let mut ctrl = ParticleController::default();
578 ctrl.update(false, false, false, false, false, false, 0.0, 0.016);
579 assert_eq!(ctrl.locomotion, ParticleLocomotion::Idle);
580 assert!(
581 (ctrl.rheology_target - 0.15).abs() < 1e-5,
582 "idle rheology_target should be 0.15, got {}",
583 ctrl.rheology_target
584 );
585 }
586
587 #[test]
588 fn particle_controller_rheology_sprinting_target() {
589 let mut ctrl = ParticleController::default();
590 ctrl.update(true, false, false, false, false, true, 0.0, 0.016);
591 assert_eq!(ctrl.locomotion, ParticleLocomotion::Sprinting);
592 assert!(
593 (ctrl.rheology_target - 0.7).abs() < 1e-5,
594 "sprinting rheology_target should be 0.7, got {}",
595 ctrl.rheology_target
596 );
597 }
598
599 #[test]
600 fn particle_controller_rheology_blend_approaches_target() {
601 let mut ctrl = ParticleController::default();
602 for _ in 0..600 {
604 ctrl.update(true, false, false, false, false, true, 0.0, 0.016);
605 }
606 assert!(
608 (ctrl.rheology_blend - ctrl.rheology_target).abs() < 0.01,
609 "after 600 frames, rheology_blend ({}) should approach target ({})",
610 ctrl.rheology_blend,
611 ctrl.rheology_target
612 );
613 }
614
615 #[test]
616 fn particle_controller_default_particle_count_256() {
617 let ctrl = ParticleController::default();
618 assert_eq!(
619 ctrl.particle_count, 256,
620 "default particle_count should be 256, got {}",
621 ctrl.particle_count
622 );
623 }
624
625 #[test]
626 fn particle_controller_default_cloud_radius_08() {
627 let ctrl = ParticleController::default();
628 assert!(
629 (ctrl.cloud_radius - 0.8).abs() < 1e-5,
630 "default cloud_radius should be 0.8, got {}",
631 ctrl.cloud_radius
632 );
633 }
634}