Skip to main content

veloren_voxygen/scene/
particle.rs

1use super::{FigureMgr, SceneData, Terrain, terrain::BlocksOfInterest};
2use crate::{
3    ecs::comp::{Footsteps, Interpolated},
4    mesh::{greedy::GreedyMesh, segment::generate_mesh_base_vol_particle},
5    render::{
6        Instances, Light, Model, ParticleDrawer, ParticleInstance, ParticleVertex, Renderer,
7        pipelines::particle::ParticleMode,
8    },
9    scene::{RAIN_THRESHOLD, terrain::FireplaceType, trail::TOOL_TRAIL_MANIFEST},
10};
11use common::{
12    assets::{AssetExt, DotVox},
13    comp::{
14        self, Beam, Body, CharacterActivity, CharacterState, Fluid, Inventory, Ori, PhysicsState,
15        Pos, Scale, Shockwave, Vel,
16        ability::Dodgeable,
17        aura, beam, biped_large, body, buff,
18        item::{ItemDefinitionId, Reagent},
19        object, shockwave,
20    },
21    figure::Segment,
22    outcome::Outcome,
23    resources::{DeltaTime, Time},
24    spiral::Spiral2d,
25    states::{self, utils::StageSection},
26    terrain::{Block, BlockKind, SpriteKind, TerrainChunk, TerrainGrid},
27    uid::IdMaps,
28    vol::{ReadVol, RectRasterableVol, SizedVol},
29};
30use common_base::prof_span;
31use hashbrown::HashMap;
32use rand::prelude::*;
33use specs::{Entity, Join, LendJoin, WorldExt};
34use std::{
35    f32::consts::{PI, TAU},
36    time::Duration,
37};
38use vek::*;
39
40pub struct ParticleMgr {
41    /// keep track of lifespans
42    particles: Vec<Particle>,
43
44    /// keep track of timings
45    scheduler: HeartbeatScheduler,
46
47    /// GPU Instance Buffer
48    instances: Instances<ParticleInstance>,
49
50    /// GPU Vertex Buffers
51    model_cache: HashMap<&'static str, Model<ParticleVertex>>,
52}
53
54impl ParticleMgr {
55    pub fn new(renderer: &mut Renderer) -> Self {
56        Self {
57            particles: Vec::new(),
58            scheduler: HeartbeatScheduler::new(),
59            instances: default_instances(renderer),
60            model_cache: default_cache(renderer),
61        }
62    }
63
64    pub fn handle_outcome(
65        &mut self,
66        outcome: &Outcome,
67        scene_data: &SceneData,
68        figure_mgr: &FigureMgr,
69    ) {
70        prof_span!("ParticleMgr::handle_outcome");
71        let time = scene_data.state.get_time();
72        let mut rng = rand::rng();
73
74        match outcome {
75            Outcome::Lightning { pos } => {
76                self.add_particles(scene_data.particles_chance, 800, || {
77                    Particle::new_directed(
78                        Duration::from_secs_f32(rng.random_range(0.5..1.0)),
79                        time,
80                        ParticleMode::Lightning,
81                        *pos + Vec3::new(0.0, 0.0, rng.random_range(0.0..600.0)),
82                        *pos,
83                        scene_data,
84                    )
85                });
86            },
87            Outcome::SpriteDelete { pos, sprite } => match sprite {
88                SpriteKind::SeaUrchin => {
89                    let pos = pos.map(|e| e as f32 + 0.5);
90                    self.add_particles(scene_data.particles_chance, 10, || {
91                        Particle::new_directed(
92                            Duration::from_secs_f32(rng.random_range(0.1..0.5)),
93                            time,
94                            ParticleMode::Steam,
95                            pos + Vec3::new(0.0, 0.0, rng.random_range(0.0..1.5)),
96                            pos,
97                            scene_data,
98                        )
99                    });
100                },
101                SpriteKind::EnsnaringVines => {},
102                _ => {},
103            },
104            Outcome::Explosion {
105                pos,
106                power,
107                radius,
108                is_attack,
109                reagent,
110            } => {
111                if *is_attack {
112                    match reagent {
113                        Some(Reagent::Green) => {
114                            self.add_particles(
115                                scene_data.particles_chance,
116                                (60.0 * power.abs()) as usize,
117                                || {
118                                    Particle::new_directed(
119                                        Duration::from_secs_f32(rng.random_range(0.2..3.0)),
120                                        time,
121                                        ParticleMode::EnergyNature,
122                                        *pos,
123                                        *pos + Vec3::<f32>::zero()
124                                            .map(|_| rng.random_range(-1.0..1.0))
125                                            .normalized()
126                                            * rng.random_range(1.0..*radius),
127                                        scene_data,
128                                    )
129                                },
130                            );
131                        },
132                        Some(Reagent::Red) => {
133                            self.add_particles(
134                                scene_data.particles_chance,
135                                (75.0 * power.abs()) as usize,
136                                || {
137                                    Particle::new_directed(
138                                        Duration::from_millis(500),
139                                        time,
140                                        ParticleMode::Explosion,
141                                        *pos,
142                                        *pos + Vec3::<f32>::zero()
143                                            .map(|_| rng.random_range(-1.0..1.0))
144                                            .normalized()
145                                            * *radius,
146                                        scene_data,
147                                    )
148                                },
149                            );
150                        },
151                        Some(Reagent::White) => {
152                            self.add_particles(
153                                scene_data.particles_chance,
154                                (75.0 * power.abs()) as usize,
155                                || {
156                                    Particle::new_directed(
157                                        Duration::from_millis(500),
158                                        time,
159                                        ParticleMode::Ice,
160                                        *pos,
161                                        *pos + Vec3::<f32>::zero()
162                                            .map(|_| rng.random_range(-1.0..1.0))
163                                            .normalized()
164                                            * *radius,
165                                        scene_data,
166                                    )
167                                },
168                            );
169                        },
170                        Some(Reagent::Purple) => {
171                            self.add_particles(
172                                scene_data.particles_chance,
173                                (75.0 * power.abs()) as usize,
174                                || {
175                                    Particle::new_directed(
176                                        Duration::from_millis(500),
177                                        time,
178                                        ParticleMode::CultistFlame,
179                                        *pos,
180                                        *pos + Vec3::<f32>::zero()
181                                            .map(|_| rng.random_range(-1.0..1.0))
182                                            .normalized()
183                                            * *radius,
184                                        scene_data,
185                                    )
186                                },
187                            );
188                        },
189                        Some(Reagent::FireRain) => {
190                            self.add_particles(
191                                scene_data.particles_chance,
192                                (5.0 * power.abs()) as usize,
193                                || {
194                                    Particle::new_directed(
195                                        Duration::from_millis(300),
196                                        time,
197                                        ParticleMode::Explosion,
198                                        *pos,
199                                        *pos + Vec3::<f32>::zero()
200                                            .map(|_| rng.random_range(-1.0..1.0))
201                                            .normalized()
202                                            * *radius,
203                                        scene_data,
204                                    )
205                                },
206                            );
207                        },
208                        Some(Reagent::FireGigas) => {
209                            self.add_particles(
210                                scene_data.particles_chance,
211                                (4.0 * radius.powi(2)) as usize,
212                                || {
213                                    Particle::new_directed(
214                                        Duration::from_millis(500),
215                                        time,
216                                        ParticleMode::FireGigasExplosion,
217                                        *pos,
218                                        *pos + Vec3::<f32>::zero()
219                                            .map(|_| rng.random_range(-1.0..1.0))
220                                            .normalized()
221                                            * *radius,
222                                        scene_data,
223                                    )
224                                },
225                            );
226                        },
227                        Some(Reagent::Earth) => {
228                            self.add_particles(scene_data.particles_chance, 150, || {
229                                Particle::new(
230                                    Duration::from_millis(250),
231                                    time,
232                                    ParticleMode::BigShrapnel,
233                                    *pos,
234                                    scene_data,
235                                )
236                            })
237                        },
238                        _ => {},
239                    }
240                } else {
241                    self.add_particles(
242                        scene_data.particles_chance,
243                        if reagent.is_some() { 300 } else { 150 },
244                        || {
245                            Particle::new(
246                                Duration::from_millis(if reagent.is_some() {
247                                    rng.random_range(3000..5000)
248                                } else {
249                                    rng.random_range(1000..2500)
250                                }),
251                                time,
252                                match reagent {
253                                    Some(Reagent::Blue) => ParticleMode::FireworkBlue,
254                                    Some(Reagent::Green) => ParticleMode::FireworkGreen,
255                                    Some(Reagent::Purple) => ParticleMode::FireworkPurple,
256                                    Some(Reagent::Red) => ParticleMode::FireworkRed,
257                                    Some(Reagent::White) => ParticleMode::FireworkWhite,
258                                    Some(Reagent::Yellow) => ParticleMode::FireworkYellow,
259                                    Some(Reagent::FireRain) => ParticleMode::FireworkYellow,
260                                    Some(Reagent::FireGigas) => ParticleMode::FireGigasExplosion,
261                                    Some(Reagent::Earth) => ParticleMode::BigShrapnel,
262                                    None => ParticleMode::Shrapnel,
263                                },
264                                *pos,
265                                scene_data,
266                            )
267                        },
268                    );
269
270                    self.add_particles(
271                        scene_data.particles_chance,
272                        if reagent.is_some() { 100 } else { 200 },
273                        || {
274                            Particle::new(
275                                Duration::from_secs(4),
276                                time,
277                                ParticleMode::CampfireSmoke,
278                                *pos + Vec3::<f32>::zero()
279                                    .map(|_| rng.random_range(-1.0..1.0))
280                                    .normalized()
281                                    * *radius,
282                                scene_data,
283                            )
284                        },
285                    );
286                }
287            },
288            Outcome::BreakBlock { pos, .. } => {
289                // TODO: Use color field when particle colors are a thing
290                self.add_particles(scene_data.particles_chance, 30, || {
291                    Particle::new(
292                        Duration::from_millis(rng.random_range(1500..2000)),
293                        time,
294                        ParticleMode::Shrapnel,
295                        pos.map(|e| e as f32 + 0.5),
296                        scene_data,
297                    )
298                });
299            },
300            Outcome::DamagedBlock {
301                pos, stage_changed, ..
302            } => {
303                self.add_particles(
304                    scene_data.particles_chance,
305                    if *stage_changed { 30 } else { 10 },
306                    || {
307                        Particle::new(
308                            Duration::from_millis(rng.random_range(1000..1500)),
309                            time,
310                            ParticleMode::Shrapnel,
311                            pos.map(|e| e as f32 + 0.5),
312                            scene_data,
313                        )
314                    },
315                );
316            },
317            Outcome::SpriteUnlocked { .. } => {},
318            Outcome::FailedSpriteUnlock { pos } => {
319                // TODO: Use color field when particle colors are a thing
320                self.add_particles(scene_data.particles_chance, 10, || {
321                    Particle::new(
322                        Duration::from_millis(50),
323                        time,
324                        ParticleMode::Shrapnel,
325                        pos.map(|e| e as f32 + 0.5),
326                        scene_data,
327                    )
328                });
329            },
330            Outcome::SummonedCreature { pos, body } => match body {
331                Body::BipedSmall(b) if matches!(b.species, body::biped_small::Species::Husk) => {
332                    let final_amount =
333                        2 * usize::from(self.scheduler.heartbeats(Duration::from_millis(1)));
334                    self.add_particles(scene_data.particles_chance, final_amount, || {
335                        let start_pos = pos + Vec3::unit_z() * body.height() / 2.0;
336                        let end_pos = pos
337                            + Vec3::new(
338                                2.0 * rng.random::<f32>() - 1.0,
339                                2.0 * rng.random::<f32>() - 1.0,
340                                0.0,
341                            )
342                            .normalized()
343                                * (body.max_radius() + 4.0)
344                            + Vec3::unit_z() * (body.height() + 2.0) * rng.random::<f32>();
345
346                        Particle::new_directed(
347                            Duration::from_secs_f32(0.5),
348                            time,
349                            ParticleMode::CultistFlame,
350                            start_pos,
351                            end_pos,
352                            scene_data,
353                        )
354                    });
355                },
356                Body::BipedSmall(b) if matches!(b.species, body::biped_small::Species::Boreal) => {
357                    let final_amount =
358                        2 * usize::from(self.scheduler.heartbeats(Duration::from_millis(1)));
359                    self.add_particles(scene_data.particles_chance, final_amount, || {
360                        let start_pos = pos + Vec3::unit_z() * body.height() / 2.0;
361                        let end_pos = pos
362                            + Vec3::new(
363                                2.0 * rng.random::<f32>() - 1.0,
364                                2.0 * rng.random::<f32>() - 1.0,
365                                0.0,
366                            )
367                            .normalized()
368                                * (body.max_radius() + 4.0)
369                            + Vec3::unit_z() * (body.height() + 20.0) * rng.random::<f32>();
370
371                        Particle::new_directed(
372                            Duration::from_secs_f32(0.5),
373                            time,
374                            ParticleMode::GigaSnow,
375                            start_pos,
376                            end_pos,
377                            scene_data,
378                        )
379                    });
380                },
381                Body::BipedSmall(b) if matches!(b.species, body::biped_small::Species::Ashen) => {
382                    let final_amount =
383                        2 * usize::from(self.scheduler.heartbeats(Duration::from_millis(1)));
384                    self.add_particles(scene_data.particles_chance, final_amount, || {
385                        let start_pos = pos + Vec3::unit_z() * body.height() / 2.0;
386                        let end_pos = pos
387                            + Vec3::new(
388                                2.0 * rng.random::<f32>() - 1.0,
389                                2.0 * rng.random::<f32>() - 1.0,
390                                0.0,
391                            )
392                            .normalized()
393                                * (body.max_radius() + 4.0)
394                            + Vec3::unit_z() * (body.height() + 20.0) * rng.random::<f32>();
395
396                        Particle::new_directed(
397                            Duration::from_secs_f32(0.5),
398                            time,
399                            ParticleMode::FlameThrower,
400                            start_pos,
401                            end_pos,
402                            scene_data,
403                        )
404                    });
405                },
406                _ => {},
407            },
408            Outcome::ProjectileHit { pos, target, .. } => {
409                if target.is_some() {
410                    let ecs = scene_data.state.ecs();
411                    if target
412                        .and_then(|target| ecs.read_resource::<IdMaps>().uid_entity(target))
413                        .and_then(|entity| {
414                            ecs.read_storage::<Body>()
415                                .get(entity)
416                                .map(|body| body.bleeds())
417                        })
418                        .unwrap_or(false)
419                    {
420                        self.add_particles(scene_data.particles_chance, 30, || {
421                            Particle::new(
422                                Duration::from_millis(250),
423                                time,
424                                ParticleMode::Blood,
425                                *pos,
426                                scene_data,
427                            )
428                        })
429                    };
430                };
431            },
432            Outcome::Block { pos, parry, .. } => {
433                if *parry {
434                    self.add_particles(scene_data.particles_chance, 10, || {
435                        Particle::new(
436                            Duration::from_millis(200),
437                            time,
438                            ParticleMode::GunPowderSpark,
439                            *pos + Vec3::unit_z(),
440                            scene_data,
441                        )
442                    });
443                }
444            },
445            Outcome::GroundSlam { pos, .. } => {
446                self.add_particles(scene_data.particles_chance, 100, || {
447                    Particle::new(
448                        Duration::from_millis(1000),
449                        time,
450                        ParticleMode::BigShrapnel,
451                        *pos,
452                        scene_data,
453                    )
454                });
455            },
456            Outcome::FireLowShockwave { pos, .. } => {
457                self.add_particles(scene_data.particles_chance, 100, || {
458                    Particle::new(
459                        Duration::from_millis(1000),
460                        time,
461                        ParticleMode::FireLowShockwave,
462                        *pos,
463                        scene_data,
464                    )
465                });
466            },
467            Outcome::SurpriseEgg { pos, .. } => {
468                self.add_particles(scene_data.particles_chance, 50, || {
469                    Particle::new(
470                        Duration::from_millis(1000),
471                        time,
472                        ParticleMode::SurpriseEgg,
473                        *pos,
474                        scene_data,
475                    )
476                });
477            },
478            Outcome::FlashFreeze { pos, .. } => {
479                let final_amount =
480                    2 * usize::from(self.scheduler.heartbeats(Duration::from_millis(1)));
481                self.add_particles(scene_data.particles_chance, final_amount, || {
482                    let start_pos = pos + Vec3::unit_z() - 1.0;
483                    let end_pos = pos
484                        + Vec3::new(
485                            4.0 * rng.random::<f32>() - 1.0,
486                            4.0 * rng.random::<f32>() - 1.0,
487                            0.0,
488                        )
489                        .normalized()
490                            * 1.5
491                        + Vec3::unit_z()
492                        + 5.0 * rng.random::<f32>();
493
494                    Particle::new_directed(
495                        Duration::from_secs_f32(0.5),
496                        time,
497                        ParticleMode::GigaSnow,
498                        start_pos,
499                        end_pos,
500                        scene_data,
501                    )
502                });
503            },
504            Outcome::CyclopsCharge { pos } => {
505                self.push_particle(
506                    scene_data.particles_chance,
507                    Particle::new_directed(
508                        Duration::from_secs_f32(rng.random_range(0.1..0.2)),
509                        time,
510                        ParticleMode::CyclopsCharge,
511                        *pos + Vec3::new(0.0, 0.0, 5.3),
512                        *pos + Vec3::new(0.0, 0.0, 5.6 + 0.5 * rng.random_range(0.0..0.2)),
513                        scene_data,
514                    ),
515                );
516            },
517            Outcome::PyroclasmCharge { .. } => {},
518            Outcome::FlamethrowerCharge { pos }
519            | Outcome::FuseCharge { pos }
520            | Outcome::FireBreathCharge { pos } => {
521                self.push_particle(
522                    scene_data.particles_chance,
523                    Particle::new_directed(
524                        Duration::from_secs_f32(rng.random_range(0.1..0.2)),
525                        time,
526                        ParticleMode::CampfireFire,
527                        *pos + Vec3::new(0.0, 0.0, 1.2),
528                        *pos + Vec3::new(0.0, 0.0, 1.5 + 0.5 * rng.random_range(0.0..0.2)),
529                        scene_data,
530                    ),
531                );
532            },
533            Outcome::TerracottaStatueCharge { pos } => {
534                self.push_particle(
535                    scene_data.particles_chance,
536                    Particle::new_directed(
537                        Duration::from_secs_f32(rng.random_range(0.1..0.2)),
538                        time,
539                        ParticleMode::FireworkYellow,
540                        *pos + Vec3::new(0.0, 0.0, 4.0),
541                        *pos + Vec3::new(0.0, 0.0, 5.0 + 0.5 * rng.random_range(0.3..0.8)),
542                        scene_data,
543                    ),
544                );
545            },
546            Outcome::Death { pos, .. } => {
547                self.add_particles(scene_data.particles_chance, 40, || {
548                    Particle::new(
549                        Duration::from_millis(400 + rng.random_range(0..100)),
550                        time,
551                        ParticleMode::Death,
552                        *pos + Vec3::unit_z()
553                            + Vec3::<f32>::zero()
554                                .map(|_| rng.random_range(-0.1..0.1))
555                                .normalized(),
556                        scene_data,
557                    )
558                });
559            },
560            Outcome::GroundDig { pos, .. } => {
561                self.add_particles(scene_data.particles_chance, 12, || {
562                    Particle::new(
563                        Duration::from_millis(200),
564                        time,
565                        ParticleMode::BigShrapnel,
566                        *pos,
567                        scene_data,
568                    )
569                });
570            },
571            Outcome::TeleportedByPortal { pos, .. } => {
572                self.add_particles(scene_data.particles_chance, 80, || {
573                    Particle::new_directed(
574                        Duration::from_millis(500),
575                        time,
576                        ParticleMode::CultistFlame,
577                        *pos,
578                        pos + Vec3::unit_z()
579                            + Vec3::zero()
580                                .map(|_: f32| rng.random_range(-0.1..0.1))
581                                .normalized()
582                                * 2.0,
583                        scene_data,
584                    )
585                });
586            },
587            Outcome::ClayGolemDash { pos, .. } => {
588                self.add_particles(scene_data.particles_chance, 100, || {
589                    Particle::new(
590                        Duration::from_millis(1000),
591                        time,
592                        ParticleMode::ClayShrapnel,
593                        *pos,
594                        scene_data,
595                    )
596                });
597            },
598            Outcome::HeadLost { uid, head } => {
599                if let Some(entity) = scene_data
600                    .state
601                    .ecs()
602                    .read_resource::<IdMaps>()
603                    .uid_entity(*uid)
604                    && let Some(pos) = scene_data.state.read_component_copied::<Pos>(entity)
605                {
606                    let heads = figure_mgr.get_heads(scene_data, entity);
607                    let head_pos = pos.0 + heads.get(*head).copied().unwrap_or_default();
608
609                    self.add_particles(scene_data.particles_chance, 40, || {
610                        Particle::new(
611                            Duration::from_millis(1000),
612                            time,
613                            ParticleMode::Death,
614                            head_pos
615                                + Vec3::<f32>::zero()
616                                    .map(|_| rng.random_range(-0.1..0.1))
617                                    .normalized(),
618                            scene_data,
619                        )
620                    });
621                };
622            },
623            Outcome::Splash {
624                vel,
625                pos,
626                mass,
627                kind,
628            } => {
629                let mode = match kind {
630                    comp::fluid_dynamics::LiquidKind::Water => ParticleMode::WaterFoam,
631                    comp::fluid_dynamics::LiquidKind::Lava => ParticleMode::CampfireFire,
632                };
633                let magnitude = (-vel.z).max(0.0);
634                let energy = mass * magnitude;
635                if energy > 0.0 {
636                    let count = ((2.5 * energy.sqrt()).ceil() as usize).min(500);
637                    let mut i = 0;
638                    let r = 0.5 / count as f32;
639                    self.add_particles(scene_data.particles_chance, count, || {
640                        let t = i as f32 / count as f32 + rng.random_range(-r..=r);
641                        i += 1;
642                        let angle = t * TAU;
643                        let s = angle.sin();
644                        let c = angle.cos();
645                        let energy = energy
646                            * f32::abs(
647                                rng.random_range(0.0..1.0) + rng.random_range(0.0..1.0) - 0.5,
648                            );
649
650                        let axis = -Vec3::unit_z();
651                        let plane = Vec3::new(c, s, 0.0);
652
653                        let pos = *pos + plane * rng.random_range(0.0..0.5);
654
655                        let energy = energy.sqrt() * 0.5;
656
657                        let dir = plane * (1.0 + energy) - axis * energy * 1.5;
658
659                        Particle::new_directed(
660                            Duration::from_millis(4000),
661                            time,
662                            mode,
663                            pos,
664                            pos + dir,
665                            scene_data,
666                        )
667                    });
668                }
669            },
670            Outcome::Transformation { pos } => {
671                self.add_particles(scene_data.particles_chance, 100, || {
672                    Particle::new(
673                        Duration::from_millis(1400),
674                        time,
675                        ParticleMode::Transformation,
676                        *pos,
677                        scene_data,
678                    )
679                });
680            },
681            Outcome::FirePillarIndicator { pos, radius } => {
682                self.add_particles(
683                    scene_data.particles_chance,
684                    radius.powi(2) as usize / 2,
685                    || {
686                        Particle::new_directed(
687                            Duration::from_millis(500),
688                            time,
689                            ParticleMode::FirePillarIndicator,
690                            *pos + 0.2 * Vec3::<f32>::unit_z(),
691                            // unit_x choosen arbitrarily, particle shader only uses the distance
692                            // between pos1 and pos2, not the actual position
693                            *pos + 0.2 * Vec3::<f32>::unit_z() + *radius * Vec3::unit_x(),
694                            scene_data,
695                        )
696                    },
697                );
698            },
699            Outcome::ProjectileShot { .. }
700            | Outcome::Beam { .. }
701            | Outcome::ExpChange { .. }
702            | Outcome::SkillPointGain { .. }
703            | Outcome::ComboChange { .. }
704            | Outcome::HealthChange { .. }
705            | Outcome::PoiseChange { .. }
706            | Outcome::Utterance { .. }
707            | Outcome::IceSpikes { .. }
708            | Outcome::IceCrack { .. }
709            | Outcome::Glider { .. }
710            | Outcome::Whoosh { .. }
711            | Outcome::Swoosh { .. }
712            | Outcome::Slash { .. }
713            | Outcome::Bleep { .. }
714            | Outcome::Charge { .. }
715            | Outcome::Steam { .. }
716            | Outcome::FireShockwave { .. }
717            | Outcome::PortalActivated { .. }
718            | Outcome::FromTheAshes { .. }
719            | Outcome::LaserBeam { .. } => {},
720        }
721    }
722
723    pub fn maintain(
724        &mut self,
725        renderer: &mut Renderer,
726        scene_data: &SceneData,
727        terrain: &Terrain<TerrainChunk>,
728        figure_mgr: &FigureMgr,
729        lights: &mut Vec<Light>,
730    ) {
731        prof_span!("ParticleMgr::maintain");
732        if scene_data.particles_enabled {
733            // update timings
734            self.scheduler.maintain(scene_data.state.get_time());
735
736            // remove dead Particle
737            self.particles
738                .retain(|p| p.alive_until > scene_data.state.get_time());
739
740            // add new Particle
741            self.maintain_equipment_particles(scene_data, figure_mgr);
742            self.maintain_body_particles(scene_data);
743            self.maintain_char_state_particles(scene_data, figure_mgr);
744            self.maintain_beam_particles(scene_data, lights);
745            self.maintain_block_particles(scene_data, terrain, figure_mgr);
746            self.maintain_shockwave_particles(scene_data);
747            self.maintain_aura_particles(scene_data);
748            self.maintain_buff_particles(scene_data);
749            self.maintain_fluid_particles(scene_data);
750            self.maintain_marker_particles(scene_data);
751            self.maintain_arcing_particles(scene_data);
752            self.maintain_pool_particles(scene_data);
753
754            self.upload_particles(renderer);
755        } else {
756            // remove all particle lifespans
757            if !self.particles.is_empty() {
758                self.particles.clear();
759                self.upload_particles(renderer);
760            }
761
762            // remove all timings
763            self.scheduler.clear();
764        }
765    }
766
767    fn push_particle(&mut self, chance: f32, particle: Particle) {
768        let mut rng = rand::rng();
769        if rng.random_bool(chance.into()) {
770            self.particles.push(particle);
771        }
772    }
773
774    fn add_particles<F>(&mut self, chance: f32, amount: usize, f: F)
775    where
776        F: FnMut() -> Particle,
777    {
778        self.particles
779            .resize_with(self.particles.len() + (amount as f32 * chance) as usize, f);
780    }
781
782    fn maintain_equipment_particles(&mut self, scene_data: &SceneData, figure_mgr: &FigureMgr) {
783        prof_span!("ParticleMgr::maintain_armor_particles");
784        let ecs = scene_data.state.ecs();
785
786        for (entity, body, scale, inv, physics) in (
787            &ecs.entities(),
788            &ecs.read_storage::<Body>(),
789            ecs.read_storage::<Scale>().maybe(),
790            &ecs.read_storage::<Inventory>(),
791            &ecs.read_storage::<PhysicsState>(),
792        )
793            .join()
794        {
795            for item in inv.equipped_items() {
796                if let ItemDefinitionId::Simple(str) = item.item_definition_id() {
797                    match &*str {
798                        "common.items.armor.misc.head.pipe" => self.maintain_pipe_particles(
799                            scene_data, figure_mgr, entity, body, scale, physics,
800                        ),
801                        "common.items.npc_weapons.sword.gigas_fire_sword" => {
802                            if let Some(trail_points) = TOOL_TRAIL_MANIFEST.get(item) {
803                                self.maintain_gigas_fire_sword_particles(
804                                    scene_data,
805                                    figure_mgr,
806                                    trail_points,
807                                    entity,
808                                )
809                            }
810                        },
811                        _ => {},
812                    }
813                }
814            }
815        }
816    }
817
818    fn maintain_pipe_particles(
819        &mut self,
820        scene_data: &SceneData,
821        figure_mgr: &FigureMgr,
822        entity: Entity,
823        body: &Body,
824        scale: Option<&Scale>,
825        physics: &PhysicsState,
826    ) {
827        prof_span!("ParticleMgr::maintain_pipe_particles");
828        if physics
829            .in_liquid()
830            .is_none_or(|depth| body.eye_height(scale.map_or(1.0, |scale| scale.0)) > depth)
831        {
832            let Body::Humanoid(body) = body else {
833                return;
834            };
835            let Some(state) = figure_mgr.states.character_states.get(&entity) else {
836                return;
837            };
838
839            // TODO: compute offsets instead of hardcoding
840            use body::humanoid::{BodyType::*, Species::*};
841            let pipe_offset = match (body.species, body.body_type) {
842                (Orc, Male) => Vec3::new(5.5, 10.5, 0.0),
843                (Orc, Female) => Vec3::new(4.5, 10.0, -2.5),
844                (Human, Male) => Vec3::new(4.5, 12.0, -3.0),
845                (Human, Female) => Vec3::new(4.5, 11.5, -3.0),
846                (Elf, Male) => Vec3::new(4.5, 12.0, -3.0),
847                (Elf, Female) => Vec3::new(4.5, 9.5, -3.0),
848                (Dwarf, Male) => Vec3::new(4.5, 11.0, -4.0),
849                (Dwarf, Female) => Vec3::new(4.5, 11.0, -3.0),
850                (Draugr, Male) => Vec3::new(4.5, 9.5, -0.75),
851                (Draugr, Female) => Vec3::new(4.5, 9.5, -2.0),
852                (Danari, Male) => Vec3::new(4.5, 10.5, -1.25),
853                (Danari, Female) => Vec3::new(4.5, 10.5, -1.25),
854            };
855
856            let mut rng = rand::rng();
857            let dt = scene_data.state.get_delta_time();
858            if rng.random_bool((0.25 * dt as f64).min(1.0)) {
859                let time = scene_data.state.get_time();
860                self.add_particles(scene_data.particles_chance, 10, || {
861                    Particle::new(
862                        Duration::from_millis(1500),
863                        time,
864                        ParticleMode::PipeSmoke,
865                        state.wpos_of(state.computed_skeleton.head.mul_point(pipe_offset)),
866                        scene_data,
867                    )
868                });
869            }
870        }
871    }
872
873    fn maintain_gigas_fire_sword_particles(
874        &mut self,
875        scene_data: &SceneData,
876        figure_mgr: &FigureMgr,
877        trail_points: (Vec3<f32>, Vec3<f32>),
878        entity: Entity,
879    ) {
880        prof_span!("ParticleMgr::maintain_gigas_fire_sword_particles");
881        let Some(state) = figure_mgr.states.biped_large_states.get(&entity) else {
882            return;
883        };
884
885        let mut rng = rand::rng();
886        let time = scene_data.state.get_time();
887        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(10)) {
888            let blade_offset = trail_points.0
889                + rng.random_range(0.0..1.0_f32) * (trail_points.1 - trail_points.0)
890                + rng.random_range(-5.0..5.0) * Vec3::<f32>::unit_y()
891                + rng.random_range(-1.0..1.0) * Vec3::<f32>::unit_x();
892
893            let start_pos = state.wpos_of(state.computed_skeleton.main.mul_point(blade_offset));
894            let end_pos = start_pos + rng.random_range(1.0..2.0) * Vec3::<f32>::unit_z();
895
896            self.push_particle(
897                scene_data.particles_chance,
898                Particle::new_directed(
899                    Duration::from_millis(500),
900                    time,
901                    ParticleMode::FlameThrower,
902                    start_pos,
903                    end_pos,
904                    scene_data,
905                ),
906            );
907        }
908    }
909
910    fn maintain_fluid_particles(&mut self, scene_data: &SceneData) {
911        prof_span!("ParticleMgr::maintain_fluid_particles");
912        let ecs = scene_data.state.ecs();
913        for (pos, vel, collider) in (
914            &ecs.read_storage::<Pos>(),
915            &ecs.read_storage::<Vel>(),
916            &ecs.read_storage::<comp::Collider>(),
917        )
918            .join()
919        {
920            // Point particles (like arrows) travelling at high velocity in water create
921            // cavitation bubbles
922            const CAVITATION_SPEED: f32 = 20.0;
923            if matches!(collider, comp::Collider::Point)
924                && let speed = vel.0.magnitude()
925                && speed > CAVITATION_SPEED
926                && scene_data
927                    .state
928                    .terrain()
929                    // Offset reduces bubbles appearing above the water
930                    .get((pos.0 + Vec3::unit_z()).as_())
931                    .is_ok_and(|b| b.kind() == BlockKind::Water)
932            {
933                let mut rng = rand::rng();
934                let time = scene_data.state.get_time();
935                let dt = scene_data.state.get_delta_time();
936                for _ in 0..self
937                    .scheduler
938                    .heartbeats(Duration::from_millis(1000 / speed.min(500.0) as u64))
939                {
940                    self.push_particle(
941                        scene_data.particles_chance,
942                        Particle::new(
943                            Duration::from_secs(1),
944                            time,
945                            ParticleMode::Bubble,
946                            pos.0.map(|e| e + rng.random_range(-0.1..0.1))
947                                - vel.0 * dt * rng.random::<f32>(),
948                            scene_data,
949                        ),
950                    );
951                }
952            }
953        }
954    }
955
956    fn maintain_body_particles(&mut self, scene_data: &SceneData) {
957        prof_span!("ParticleMgr::maintain_body_particles");
958        let ecs = scene_data.state.ecs();
959        for (body, interpolated, vel) in (
960            &ecs.read_storage::<Body>(),
961            &ecs.read_storage::<Interpolated>(),
962            ecs.read_storage::<Vel>().maybe(),
963        )
964            .join()
965        {
966            match body {
967                Body::Object(object::Body::CampfireLit) => {
968                    self.maintain_campfirelit_particles(scene_data, interpolated.pos, vel)
969                },
970                Body::Object(object::Body::BarrelOrgan) => {
971                    self.maintain_barrel_organ_particles(scene_data, interpolated.pos, vel)
972                },
973                Body::Object(object::Body::BoltFire) => {
974                    self.maintain_boltfire_particles(scene_data, interpolated.pos, vel)
975                },
976                Body::Object(object::Body::NapalmShot) => {
977                    self.maintain_napalmshot_particles(scene_data, interpolated.pos, vel)
978                },
979                Body::Object(object::Body::BoltFireBig) => {
980                    self.maintain_boltfirebig_particles(scene_data, interpolated.pos, vel)
981                },
982                Body::Object(object::Body::FireRainDrop) => {
983                    self.maintain_fireraindrop_particles(scene_data, interpolated.pos, vel)
984                },
985                Body::Object(object::Body::BoltNature) => {
986                    self.maintain_boltnature_particles(scene_data, interpolated.pos, vel)
987                },
988                Body::Object(object::Body::Tornado) => {
989                    self.maintain_tornado_particles(scene_data, interpolated.pos)
990                },
991                Body::Object(object::Body::FieryTornado) => {
992                    self.maintain_fiery_tornado_particles(scene_data, interpolated.pos)
993                },
994                Body::Object(object::Body::Mine) => {
995                    self.maintain_mine_particles(scene_data, interpolated.pos)
996                },
997                Body::Object(
998                    object::Body::Bomb
999                    | object::Body::FireworkBlue
1000                    | object::Body::FireworkGreen
1001                    | object::Body::FireworkPurple
1002                    | object::Body::FireworkRed
1003                    | object::Body::FireworkWhite
1004                    | object::Body::FireworkYellow
1005                    | object::Body::IronPikeBomb,
1006                ) => self.maintain_bomb_particles(scene_data, interpolated.pos, vel),
1007                Body::Object(object::Body::PortalActive) => {
1008                    self.maintain_active_portal_particles(scene_data, interpolated.pos)
1009                },
1010                Body::Object(object::Body::Portal) => {
1011                    self.maintain_portal_particles(scene_data, interpolated.pos)
1012                },
1013                Body::Object(object::Body::NapalmPool) => {
1014                    self.maintain_napalmpool_particles(scene_data, interpolated.pos)
1015                },
1016                Body::Object(object::Body::PyroclasmBolt) => {
1017                    self.maintain_pyroclasm_bolt_particles(scene_data, interpolated.pos, vel)
1018                },
1019                Body::BipedLarge(biped_large::Body {
1020                    species: biped_large::Species::Gigasfire,
1021                    ..
1022                }) => self.maintain_fire_gigas_particles(scene_data, interpolated.pos),
1023                _ => {},
1024            }
1025        }
1026    }
1027
1028    fn maintain_pyroclasm_bolt_particles(
1029        &mut self,
1030        scene_data: &SceneData,
1031        pos: Vec3<f32>,
1032        vel: Option<&Vel>,
1033    ) {
1034        let time = scene_data.state.get_time();
1035        let mut rng = rand::rng();
1036        let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
1037
1038        let fwd = vel
1039            .map(|v| v.0)
1040            .unwrap_or(Vec3::unit_y())
1041            .try_normalized()
1042            .unwrap_or(Vec3::unit_y());
1043
1044        self.add_particles(
1045            scene_data.particles_chance,
1046            usize::from(heartbeats) * 6,
1047            || {
1048                let spread = Vec3::new(
1049                    rng.random_range(-0.2..0.2_f32),
1050                    rng.random_range(-0.2..0.2_f32),
1051                    rng.random_range(-0.2..0.2_f32),
1052                );
1053                let spawn = pos + spread;
1054                let trail_dir = (fwd - Vec3::unit_z() * 0.4).try_normalized().unwrap_or(fwd);
1055                let tail = spawn - trail_dir * rng.random_range(0.5..2.5_f32);
1056
1057                Particle::new_directed(
1058                    Duration::from_millis(150),
1059                    time,
1060                    ParticleMode::FlameThrower,
1061                    spawn,
1062                    tail,
1063                    scene_data,
1064                )
1065            },
1066        );
1067    }
1068
1069    fn maintain_pyroclasm_charge_particles(
1070        &mut self,
1071        scene_data: &SceneData,
1072        pos: Vec3<f32>,
1073        progress: f32,
1074        height: f32,
1075        radius: f32,
1076    ) {
1077        let time = scene_data.state.get_time();
1078        let mut rng = rand::rng();
1079        let heartbeats = self.scheduler.heartbeats(Duration::from_millis(8));
1080
1081        //[Height Modifier, Radius Modifier, Direction of rotation] for each circle of
1082        //[Height latitude.
1083        const LATITUDE_MODIFIERS: [(f32, f32, f32); 5] = [
1084            (-0.85, 0.50, 1.0),
1085            (-0.50, 0.80, -1.0),
1086            (0.00, 0.95, 1.0),
1087            (0.50, 0.80, -1.0),
1088            (0.85, 0.50, 1.0),
1089        ];
1090
1091        let scale = 1.0 - progress;
1092        let center_z = height;
1093
1094        for &(sin_lat, cos_lat, dir) in &LATITUDE_MODIFIERS {
1095            //Sphere implodes
1096            let r = radius * cos_lat * scale;
1097            let z = center_z + radius * sin_lat * (1.0 - progress);
1098
1099            //Bigger spheres have more/longer lasting particles so they do not look sparse.
1100            let particle_density_hb: usize = (radius * 4.0).floor() as usize;
1101            let particle_density_lifespan: u64 = (radius * 75.0).floor() as u64;
1102
1103            self.add_particles(
1104                scene_data.particles_chance,
1105                usize::from(heartbeats) * particle_density_hb,
1106                || {
1107                    let theta = rng.random_range(0.0..TAU);
1108                    let spawn = pos + Vec3::new(theta.cos() * r, theta.sin() * r, z);
1109                    let tangent = Vec3::new(-theta.sin() * dir, theta.cos() * dir, 0.06 * dir);
1110                    let end_pos = spawn + tangent * 0.55;
1111
1112                    let blue_prob = (progress / 0.9).clamp(0.0, 1.0) as f64;
1113
1114                    let mode = if rng.random_bool(1.0 - blue_prob) {
1115                        ParticleMode::FlameThrower
1116                    } else {
1117                        ParticleMode::FlamethrowerBlue
1118                    };
1119
1120                    //No wind interaction
1121                    let lifespan: Duration = Duration::from_millis(particle_density_lifespan);
1122                    Particle {
1123                        alive_until: time + lifespan.as_secs_f64(),
1124                        instance: ParticleInstance::new_directed(
1125                            time,
1126                            lifespan.as_secs_f32(),
1127                            mode,
1128                            spawn,
1129                            end_pos,
1130                            Vec2::zero(),
1131                        ),
1132                    }
1133                },
1134            );
1135        }
1136    }
1137
1138    fn maintain_napalmshot_particles(
1139        &mut self,
1140        scene_data: &SceneData,
1141        pos: Vec3<f32>,
1142        vel: Option<&Vel>,
1143    ) {
1144        prof_span!("ParticleMgr::maintain_napalmshot_particles");
1145        let time = scene_data.state.get_time();
1146        let dt = scene_data.state.get_delta_time();
1147        let mut rng = rand::rng();
1148
1149        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(20)) {
1150            self.push_particle(
1151                scene_data.particles_chance,
1152                Particle::new(
1153                    Duration::from_millis(150),
1154                    time,
1155                    ParticleMode::GunPowderSpark,
1156                    pos.map(|e| e + rng.random_range(-0.2..0.2))
1157                        + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.random::<f32>()),
1158                    scene_data,
1159                ),
1160            );
1161        }
1162    }
1163
1164    fn maintain_napalmpool_particles(&mut self, scene_data: &SceneData, pos: Vec3<f32>) {
1165        prof_span!("ParticleMgr::maintain_napalmpool_particles");
1166        let time = scene_data.state.get_time();
1167        let mut rng = rand::rng();
1168
1169        // sparks
1170        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(50)) {
1171            self.push_particle(
1172                scene_data.particles_chance,
1173                Particle::new(
1174                    Duration::from_millis(1500),
1175                    time,
1176                    ParticleMode::GunPowderSpark,
1177                    pos.map(|e| e + rng.random_range(-0.4..0.4)),
1178                    scene_data,
1179                ),
1180            );
1181        }
1182
1183        // smoke
1184        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(50)) {
1185            self.push_particle(
1186                scene_data.particles_chance,
1187                Particle::new(
1188                    Duration::from_millis(1500),
1189                    time,
1190                    ParticleMode::CampfireSmoke,
1191                    pos.map(|e| e + rng.random_range(-0.4..0.4)),
1192                    scene_data,
1193                ),
1194            );
1195        }
1196    }
1197
1198    fn maintain_fire_gigas_particles(&mut self, scene_data: &SceneData, pos: Vec3<f32>) {
1199        let time = scene_data.state.get_time();
1200        let mut rng = rand::rng();
1201
1202        if rng.random_bool(0.05) {
1203            self.add_particles(scene_data.particles_chance, 1, || {
1204                let rand_offset = Vec3::new(
1205                    rng.random_range(-5.0..5.0),
1206                    rng.random_range(-5.0..5.0),
1207                    rng.random_range(7.0..15.0),
1208                );
1209
1210                Particle::new(
1211                    Duration::from_secs_f32(30.0),
1212                    time,
1213                    ParticleMode::FireGigasAsh,
1214                    pos + rand_offset,
1215                    scene_data,
1216                )
1217            });
1218        }
1219    }
1220
1221    fn maintain_hydra_tail_swipe_particles(
1222        &mut self,
1223        scene_data: &SceneData,
1224        figure_mgr: &FigureMgr,
1225        entity: Entity,
1226        pos: Vec3<f32>,
1227        body: &Body,
1228        state: &CharacterState,
1229        inventory: Option<&Inventory>,
1230    ) {
1231        let Some(ability_id) = state
1232            .ability_info()
1233            .and_then(|info| info.ability.map(|a| a.ability_id(Some(state), inventory)))
1234        else {
1235            return;
1236        };
1237
1238        if ability_id != Some("common.abilities.custom.hydra.tail_swipe") {
1239            return;
1240        }
1241
1242        let Some(stage_section) = state.stage_section() else {
1243            return;
1244        };
1245
1246        let particle_count = match stage_section {
1247            StageSection::Charge => 1,
1248            StageSection::Action => 10,
1249            _ => return,
1250        };
1251
1252        let Some(skeleton) = figure_mgr
1253            .states
1254            .quadruped_low_states
1255            .get(&entity)
1256            .map(|state| &state.computed_skeleton)
1257        else {
1258            return;
1259        };
1260        let Some(attr) = anim::quadruped_low::SkeletonAttr::try_from(body).ok() else {
1261            return;
1262        };
1263
1264        let start = (skeleton.tail_front * Vec4::unit_w()).xyz();
1265        let end = (skeleton.tail_rear * Vec4::new(0.0, -attr.tail_rear_length, 0.0, 1.0)).xyz();
1266
1267        let start = pos + start;
1268        let end = pos + end;
1269
1270        let time = scene_data.state.get_time();
1271        let mut rng = rand::rng();
1272
1273        let final_amount =
1274            particle_count * self.scheduler.heartbeats(Duration::from_millis(33)) as usize;
1275        self.add_particles(scene_data.particles_chance, final_amount, || {
1276            let t = rng.random_range(0.0..1.0);
1277            let p = start * t + end * (1.0 - t) - Vec3::new(0.0, 0.0, 0.5);
1278
1279            Particle::new(
1280                Duration::from_millis(500),
1281                time,
1282                ParticleMode::GroundShockwave,
1283                p,
1284                scene_data,
1285            )
1286        });
1287    }
1288
1289    fn maintain_campfirelit_particles(
1290        &mut self,
1291        scene_data: &SceneData,
1292        pos: Vec3<f32>,
1293        vel: Option<&Vel>,
1294    ) {
1295        prof_span!("ParticleMgr::maintain_campfirelit_particles");
1296        let time = scene_data.state.get_time();
1297        let dt = scene_data.state.get_delta_time();
1298        let mut rng = rand::rng();
1299
1300        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(25)) {
1301            self.push_particle(
1302                scene_data.particles_chance,
1303                Particle::new(
1304                    Duration::from_millis(800),
1305                    time,
1306                    ParticleMode::CampfireFire,
1307                    pos + Vec2::broadcast(())
1308                        .map(|_| rand::rng().random_range(-0.3..0.3))
1309                        .with_z(0.1),
1310                    scene_data,
1311                ),
1312            );
1313        }
1314
1315        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(50)) {
1316            self.push_particle(
1317                scene_data.particles_chance,
1318                Particle::new(
1319                    Duration::from_secs(10),
1320                    time,
1321                    ParticleMode::CampfireSmoke,
1322                    pos.map(|e| e + rand::rng().random_range(-0.25..0.25))
1323                        + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.random::<f32>()),
1324                    scene_data,
1325                ),
1326            );
1327        }
1328    }
1329
1330    fn maintain_barrel_organ_particles(
1331        &mut self,
1332        scene_data: &SceneData,
1333        pos: Vec3<f32>,
1334        vel: Option<&Vel>,
1335    ) {
1336        prof_span!("ParticleMgr::maintain_barrel_organ_particles");
1337        let time = scene_data.state.get_time();
1338        let dt = scene_data.state.get_delta_time();
1339        let mut rng = rand::rng();
1340
1341        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(20)) {
1342            self.push_particle(
1343                scene_data.particles_chance,
1344                Particle::new(
1345                    Duration::from_millis(250),
1346                    time,
1347                    ParticleMode::BarrelOrgan,
1348                    pos,
1349                    scene_data,
1350                ),
1351            );
1352
1353            self.push_particle(
1354                scene_data.particles_chance,
1355                Particle::new(
1356                    Duration::from_secs(10),
1357                    time,
1358                    ParticleMode::BarrelOrgan,
1359                    pos.map(|e| e + rand::rng().random_range(-0.25..0.25))
1360                        + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.random::<f32>()),
1361                    scene_data,
1362                ),
1363            );
1364        }
1365    }
1366
1367    fn maintain_boltfire_particles(
1368        &mut self,
1369        scene_data: &SceneData,
1370        pos: Vec3<f32>,
1371        vel: Option<&Vel>,
1372    ) {
1373        prof_span!("ParticleMgr::maintain_boltfire_particles");
1374        let time = scene_data.state.get_time();
1375        let dt = scene_data.state.get_delta_time();
1376        let mut rng = rand::rng();
1377
1378        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(4)) {
1379            self.push_particle(
1380                scene_data.particles_chance,
1381                Particle::new(
1382                    Duration::from_millis(500),
1383                    time,
1384                    ParticleMode::CampfireFire,
1385                    pos.map(|e| e + rng.random_range(-0.25..0.25))
1386                        + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.random::<f32>()),
1387                    scene_data,
1388                ),
1389            );
1390            self.push_particle(
1391                scene_data.particles_chance,
1392                Particle::new(
1393                    Duration::from_secs(1),
1394                    time,
1395                    ParticleMode::CampfireSmoke,
1396                    pos.map(|e| e + rng.random_range(-0.25..0.25))
1397                        + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.random::<f32>()),
1398                    scene_data,
1399                ),
1400            );
1401        }
1402    }
1403
1404    fn maintain_boltfirebig_particles(
1405        &mut self,
1406        scene_data: &SceneData,
1407        pos: Vec3<f32>,
1408        vel: Option<&Vel>,
1409    ) {
1410        prof_span!("ParticleMgr::maintain_boltfirebig_particles");
1411        let time = scene_data.state.get_time();
1412        let dt = scene_data.state.get_delta_time();
1413        let mut rng = rand::rng();
1414
1415        // fire
1416        let final_amount = usize::from(self.scheduler.heartbeats(Duration::from_millis(2)));
1417        self.add_particles(scene_data.particles_chance, final_amount, || {
1418            Particle::new(
1419                Duration::from_millis(500),
1420                time,
1421                ParticleMode::CampfireFire,
1422                pos.map(|e| e + rng.random_range(-0.25..0.25))
1423                    + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.random::<f32>()),
1424                scene_data,
1425            )
1426        });
1427
1428        // smoke
1429        let final_amount = usize::from(self.scheduler.heartbeats(Duration::from_millis(5)));
1430        self.add_particles(scene_data.particles_chance, final_amount, || {
1431            Particle::new(
1432                Duration::from_secs(2),
1433                time,
1434                ParticleMode::CampfireSmoke,
1435                pos.map(|e| e + rng.random_range(-0.25..0.25))
1436                    + vel.map_or(Vec3::zero(), |v| -v.0 * dt),
1437                scene_data,
1438            )
1439        });
1440    }
1441
1442    fn maintain_fireraindrop_particles(
1443        &mut self,
1444        scene_data: &SceneData,
1445        pos: Vec3<f32>,
1446        vel: Option<&Vel>,
1447    ) {
1448        prof_span!("ParticleMgr::maintain_fireraindrop_particles");
1449        let time = scene_data.state.get_time();
1450        let dt = scene_data.state.get_delta_time();
1451        let mut rng = rand::rng();
1452
1453        // trace
1454        let final_amount = usize::from(self.scheduler.heartbeats(Duration::from_millis(100)));
1455        self.add_particles(scene_data.particles_chance, final_amount, || {
1456            Particle::new(
1457                Duration::from_millis(300),
1458                time,
1459                ParticleMode::FieryDropletTrace,
1460                pos.map(|e| e + rng.random_range(-0.25..0.25))
1461                    + Vec3::new(0.0, 0.0, 0.5)
1462                    + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.random::<f32>()),
1463                scene_data,
1464            )
1465        });
1466    }
1467
1468    fn maintain_boltnature_particles(
1469        &mut self,
1470        scene_data: &SceneData,
1471        pos: Vec3<f32>,
1472        vel: Option<&Vel>,
1473    ) {
1474        let time = scene_data.state.get_time();
1475        let dt = scene_data.state.get_delta_time();
1476        let mut rng = rand::rng();
1477
1478        // nature
1479        let final_amount = usize::from(self.scheduler.heartbeats(Duration::from_millis(2)));
1480        self.add_particles(scene_data.particles_chance, final_amount, || {
1481            Particle::new(
1482                Duration::from_millis(500),
1483                time,
1484                ParticleMode::CampfireSmoke,
1485                pos.map(|e| e + rng.random_range(-0.25..0.25))
1486                    + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.random::<f32>()),
1487                scene_data,
1488            )
1489        });
1490    }
1491
1492    fn maintain_tornado_particles(&mut self, scene_data: &SceneData, pos: Vec3<f32>) {
1493        let time = scene_data.state.get_time();
1494        let mut rng = rand::rng();
1495
1496        // air particles
1497        let final_amount = usize::from(self.scheduler.heartbeats(Duration::from_millis(5)));
1498        self.add_particles(scene_data.particles_chance, final_amount, || {
1499            Particle::new(
1500                Duration::from_millis(1000),
1501                time,
1502                ParticleMode::Tornado,
1503                pos.map(|e| e + rng.random_range(-0.25..0.25)),
1504                scene_data,
1505            )
1506        });
1507    }
1508
1509    fn maintain_fiery_tornado_particles(&mut self, scene_data: &SceneData, pos: Vec3<f32>) {
1510        let time = scene_data.state.get_time();
1511        let mut rng = rand::rng();
1512
1513        // air particles
1514        let final_amount = usize::from(self.scheduler.heartbeats(Duration::from_millis(5)));
1515        self.add_particles(scene_data.particles_chance, final_amount, || {
1516            Particle::new(
1517                Duration::from_millis(1000),
1518                time,
1519                ParticleMode::FieryTornado,
1520                pos.map(|e| e + rng.random_range(-0.25..0.25)),
1521                scene_data,
1522            )
1523        });
1524    }
1525
1526    fn maintain_bomb_particles(
1527        &mut self,
1528        scene_data: &SceneData,
1529        pos: Vec3<f32>,
1530        vel: Option<&Vel>,
1531    ) {
1532        prof_span!("ParticleMgr::maintain_bomb_particles");
1533        let time = scene_data.state.get_time();
1534        let dt = scene_data.state.get_delta_time();
1535        let mut rng = rand::rng();
1536
1537        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(10)) {
1538            // sparks
1539            self.push_particle(
1540                scene_data.particles_chance,
1541                Particle::new(
1542                    Duration::from_millis(1500),
1543                    time,
1544                    ParticleMode::GunPowderSpark,
1545                    pos,
1546                    scene_data,
1547                ),
1548            );
1549
1550            // smoke
1551            self.push_particle(
1552                scene_data.particles_chance,
1553                Particle::new(
1554                    Duration::from_secs(2),
1555                    time,
1556                    ParticleMode::CampfireSmoke,
1557                    pos + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.random::<f32>()),
1558                    scene_data,
1559                ),
1560            );
1561        }
1562    }
1563
1564    fn maintain_active_portal_particles(&mut self, scene_data: &SceneData, pos: Vec3<f32>) {
1565        prof_span!("ParticleMgr::maintain_active_portal_particles");
1566
1567        let time = scene_data.state.get_time();
1568        let mut rng = rand::rng();
1569
1570        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(5)) {
1571            let outer_pos =
1572                pos + (Vec2::unit_x().rotated_z(rng.random_range((0.)..PI * 2.)) * 2.7).with_z(0.);
1573
1574            self.push_particle(
1575                scene_data.particles_chance,
1576                Particle::new_directed(
1577                    Duration::from_secs_f32(rng.random_range(0.4..0.8)),
1578                    time,
1579                    ParticleMode::CultistFlame,
1580                    outer_pos,
1581                    outer_pos + Vec3::unit_z() * rng.random_range(5.0..7.0),
1582                    scene_data,
1583                ),
1584            );
1585        }
1586    }
1587
1588    fn maintain_portal_particles(&mut self, scene_data: &SceneData, pos: Vec3<f32>) {
1589        prof_span!("ParticleMgr::maintain_portal_particles");
1590
1591        let time = scene_data.state.get_time();
1592        let mut rng = rand::rng();
1593
1594        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(150)) {
1595            let outer_pos = pos
1596                + (Vec2::unit_x().rotated_z(rng.random_range((0.)..PI * 2.))
1597                    * rng.random_range(1.0..2.9))
1598                .with_z(0.);
1599
1600            self.push_particle(
1601                scene_data.particles_chance,
1602                Particle::new_directed(
1603                    Duration::from_secs_f32(rng.random_range(0.5..3.0)),
1604                    time,
1605                    ParticleMode::CultistFlame,
1606                    outer_pos,
1607                    outer_pos + Vec3::unit_z() * rng.random_range(3.0..4.0),
1608                    scene_data,
1609                ),
1610            );
1611        }
1612    }
1613
1614    fn maintain_mine_particles(&mut self, scene_data: &SceneData, pos: Vec3<f32>) {
1615        prof_span!("ParticleMgr::maintain_mine_particles");
1616        let time = scene_data.state.get_time();
1617
1618        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(1)) {
1619            // sparks
1620            self.push_particle(
1621                scene_data.particles_chance,
1622                Particle::new(
1623                    Duration::from_millis(25),
1624                    time,
1625                    ParticleMode::GunPowderSpark,
1626                    pos,
1627                    scene_data,
1628                ),
1629            );
1630        }
1631    }
1632
1633    fn maintain_char_state_particles(&mut self, scene_data: &SceneData, figure_mgr: &FigureMgr) {
1634        prof_span!("ParticleMgr::maintain_char_state_particles");
1635        let state = scene_data.state;
1636        let ecs = state.ecs();
1637        let time = state.get_time();
1638        let dt = scene_data.state.get_delta_time();
1639        let mut rng = rand::rng();
1640
1641        for (
1642            entity,
1643            interpolated,
1644            vel,
1645            character_state,
1646            body,
1647            ori,
1648            character_activity,
1649            physics,
1650            inventory,
1651            footsteps,
1652            scale,
1653        ) in (
1654            &ecs.entities(),
1655            &ecs.read_storage::<Interpolated>(),
1656            ecs.read_storage::<Vel>().maybe(),
1657            &ecs.read_storage::<CharacterState>(),
1658            &ecs.read_storage::<Body>(),
1659            &ecs.read_storage::<Ori>(),
1660            &ecs.read_storage::<CharacterActivity>(),
1661            &ecs.read_storage::<PhysicsState>(),
1662            ecs.read_storage::<Inventory>().maybe(),
1663            ecs.read_storage::<Footsteps>().maybe(),
1664            ecs.read_storage::<Scale>().maybe(),
1665        )
1666            .join()
1667        {
1668            // Footsteps
1669            if let Some(block) = physics.on_ground
1670                && let Some(color) = block.get_color()
1671                && let Some(vel) = vel
1672                && (vel.0 - physics.ground_vel).xy().magnitude_squared() > 30.0
1673                && matches!(body, Body::Humanoid(_))
1674                && let Some(char_state) = figure_mgr.states.character_states.get(&entity)
1675                && let Some(footsteps) = footsteps
1676            {
1677                let feet = [
1678                    char_state.computed_skeleton.foot_l,
1679                    char_state.computed_skeleton.foot_r,
1680                ];
1681
1682                let feet_pos = feet.map(|f| {
1683                    char_state
1684                        .wpos_of(f.mul_point(Vec3::zero()))
1685                        .with_z(interpolated.pos.z)
1686                });
1687
1688                for (i, foot_pos) in feet_pos.into_iter().enumerate() {
1689                    if footsteps.is_stepping(i) {
1690                        let scale = scale.map_or(1.0, |s| s.0);
1691                        // Kicking up dust/particles
1692                        let dust_particles = (scale * 4.0).ceil() as usize;
1693                        self.add_particles(scene_data.particles_chance, dust_particles, || {
1694                            Particle::new_colored(
1695                                Duration::from_millis(750),
1696                                time,
1697                                ParticleMode::Dust,
1698                                // Spread the particles around the foot
1699                                foot_pos
1700                                    + Vec2::broadcast(scale)
1701                                        .map(|s| rng.random_range(-0.1..0.1) * s),
1702                                // To avoid the particle being unduly lit in dark areas, have
1703                                // it take on the voxel
1704                                // lighting conditions of the parent figure.
1705                                color.as_() * (1.0 / 255.0),
1706                                scene_data,
1707                            )
1708                            .with_light(char_state.meta.last_light, char_state.meta.last_glow.1)
1709                        });
1710                        // Exposure to sunlight: proxy for how wet the ground is
1711                        if char_state.meta.last_light > 0.9 {
1712                            // Splashing in the rain
1713                            let splash_particles =
1714                                ((state.weather_at(interpolated.pos.xy()).rain - RAIN_THRESHOLD)
1715                                    .max(0.0)
1716                                    * scale
1717                                    * 100.0)
1718                                    .ceil()
1719                                    .min(16.0) as usize;
1720                            self.add_particles(
1721                                scene_data.particles_chance,
1722                                splash_particles,
1723                                || {
1724                                    Particle::new_directed(
1725                                        Duration::from_millis(2500),
1726                                        time,
1727                                        ParticleMode::WaterFoam,
1728                                        foot_pos,
1729                                        foot_pos
1730                                            + (Vec2::broadcast(())
1731                                                .map(|()| rng.random_range(-1.0..1.0))
1732                                                .try_normalized()
1733                                                .unwrap_or_default()
1734                                                * rng.random_range(9.0..12.0))
1735                                            .with_z(13.0),
1736                                        scene_data,
1737                                    )
1738                                    .with_light(
1739                                        char_state.meta.last_light,
1740                                        char_state.meta.last_glow.1,
1741                                    )
1742                                },
1743                            );
1744                        }
1745                    }
1746                }
1747            }
1748
1749            // Bubbles when swimming
1750            if let Some(fluid) = physics.in_fluid
1751                && fluid.is_water()
1752                && let Some(vel) = vel
1753                && fluid.relative_flow(vel).0.magnitude_squared() > 10.0
1754                && matches!(body, Body::Humanoid(_))
1755                && let Some(state) = figure_mgr.states.character_states.get(&entity)
1756            {
1757                for hand in [
1758                    state.computed_skeleton.hand_l,
1759                    state.computed_skeleton.hand_r,
1760                ] {
1761                    // This offset check is a huge hack and probably doesn't work well at very low
1762                    // framerates. Unfortunately, there's not really a better way to do this today,
1763                    // because animations have no way to emit events.
1764                    if hand.mul_direction(Vec3::unit_z()).z < 0.0 {
1765                        let final_amount =
1766                            usize::from(self.scheduler.heartbeats(Duration::from_millis(90)));
1767                        self.add_particles(scene_data.particles_chance, final_amount, || {
1768                            Particle::new(
1769                                Duration::from_secs(1),
1770                                time,
1771                                ParticleMode::Bubble,
1772                                state.wpos_of(hand.mul_point(Vec3::zero()))
1773                                    - vel.0 * dt * rng.random::<f32>(),
1774                                scene_data,
1775                            )
1776                        });
1777                    }
1778                }
1779            }
1780
1781            match character_state {
1782                CharacterState::Boost(_) => {
1783                    let final_amount =
1784                        usize::from(self.scheduler.heartbeats(Duration::from_millis(10)));
1785                    self.add_particles(scene_data.particles_chance, final_amount, || {
1786                        Particle::new(
1787                            Duration::from_millis(250),
1788                            time,
1789                            ParticleMode::PortalFizz,
1790                            // Output particles from broom, not from character ass
1791                            interpolated.pos
1792                                - ori.to_horizontal().look_dir().to_vec()
1793                                - vel.map_or(Vec3::zero(), |v| v.0 * dt * rng.random::<f32>()),
1794                            scene_data,
1795                        )
1796                    });
1797                },
1798                CharacterState::BasicMelee(c) => {
1799                    if let Some(specifier) = c.static_data.frontend_specifier {
1800                        match specifier {
1801                            states::basic_melee::FrontendSpecifier::FlameTornado => {
1802                                if matches!(c.stage_section, StageSection::Action) {
1803                                    let time = scene_data.state.get_time();
1804                                    let mut rng = rand::rng();
1805                                    let final_amount = 10
1806                                        + usize::from(
1807                                            self.scheduler.heartbeats(Duration::from_millis(5)),
1808                                        );
1809                                    self.add_particles(
1810                                        scene_data.particles_chance,
1811                                        final_amount,
1812                                        || {
1813                                            Particle::new(
1814                                                Duration::from_millis(1000),
1815                                                time,
1816                                                ParticleMode::FlameTornado,
1817                                                interpolated
1818                                                    .pos
1819                                                    .map(|e| e + rng.random_range(-0.25..0.25)),
1820                                                scene_data,
1821                                            )
1822                                        },
1823                                    );
1824                                }
1825                            },
1826                            states::basic_melee::FrontendSpecifier::FireGigasWhirlwind => {
1827                                if matches!(c.stage_section, StageSection::Action) {
1828                                    let time = scene_data.state.get_time();
1829                                    let mut rng = rand::rng();
1830                                    let final_amount = 3 + usize::from(
1831                                        self.scheduler.heartbeats(Duration::from_millis(5)),
1832                                    );
1833                                    self.add_particles(
1834                                        scene_data.particles_chance,
1835                                        final_amount,
1836                                        || {
1837                                            Particle::new(
1838                                                Duration::from_millis(600),
1839                                                time,
1840                                                ParticleMode::FireGigasWhirlwind,
1841                                                interpolated
1842                                                    .pos
1843                                                    .map(|e| e + rng.random_range(-0.25..0.25))
1844                                                    + 3.0 * Vec3::<f32>::unit_z(),
1845                                                scene_data,
1846                                            )
1847                                        },
1848                                    );
1849                                }
1850                            },
1851                        }
1852                    }
1853                },
1854                CharacterState::RapidMelee(c) => {
1855                    if let Some(specifier) = c.static_data.frontend_specifier {
1856                        match specifier {
1857                            states::rapid_melee::FrontendSpecifier::CultistVortex => {
1858                                if matches!(c.stage_section, StageSection::Action) {
1859                                    let range = c.static_data.melee_constructor.range;
1860                                    // Particles for vortex
1861                                    let heartbeats =
1862                                        self.scheduler.heartbeats(Duration::from_millis(3));
1863                                    self.add_particles(
1864                                        scene_data.particles_chance,
1865                                        range.powi(2) as usize * usize::from(heartbeats) / 150,
1866                                        || {
1867                                            let rand_dist =
1868                                                range * (1.0 - rng.random::<f32>().powi(10));
1869                                            let init_pos = Vec3::new(
1870                                                2.0 * rng.random::<f32>() - 1.0,
1871                                                2.0 * rng.random::<f32>() - 1.0,
1872                                                0.0,
1873                                            )
1874                                            .normalized()
1875                                                * rand_dist
1876                                                + interpolated.pos
1877                                                + Vec3::unit_z() * 0.05;
1878                                            Particle::new_directed(
1879                                                Duration::from_millis(900),
1880                                                time,
1881                                                ParticleMode::CultistFlame,
1882                                                init_pos,
1883                                                interpolated.pos,
1884                                                scene_data,
1885                                            )
1886                                        },
1887                                    );
1888                                    // Particles for lifesteal effect
1889                                    for (_entity_b, interpolated_b, body_b, _health_b) in (
1890                                        &ecs.entities(),
1891                                        &ecs.read_storage::<Interpolated>(),
1892                                        &ecs.read_storage::<Body>(),
1893                                        &ecs.read_storage::<comp::Health>(),
1894                                    )
1895                                        .join()
1896                                        .filter(|(e, _, _, h)| !h.is_dead && entity != *e)
1897                                    {
1898                                        if interpolated.pos.distance_squared(interpolated_b.pos)
1899                                            < range.powi(2)
1900                                        {
1901                                            let heartbeats = self
1902                                                .scheduler
1903                                                .heartbeats(Duration::from_millis(20));
1904                                            self.add_particles(
1905                                                scene_data.particles_chance,
1906                                                range.powi(2) as usize * usize::from(heartbeats)
1907                                                    / 150,
1908                                                || {
1909                                                    let start_pos = interpolated_b.pos
1910                                                        + Vec3::unit_z() * body_b.height() * 0.5
1911                                                        + Vec3::<f32>::zero()
1912                                                            .map(|_| rng.random_range(-1.0..1.0))
1913                                                            .normalized()
1914                                                            * 1.0;
1915                                                    Particle::new_directed(
1916                                                        Duration::from_millis(900),
1917                                                        time,
1918                                                        ParticleMode::CultistFlame,
1919                                                        start_pos,
1920                                                        interpolated.pos
1921                                                            + Vec3::unit_z() * body.height() * 0.5,
1922                                                        scene_data,
1923                                                    )
1924                                                },
1925                                            );
1926                                        }
1927                                    }
1928                                }
1929                            },
1930                            states::rapid_melee::FrontendSpecifier::IceWhirlwind => {
1931                                if matches!(c.stage_section, StageSection::Action) {
1932                                    let time = scene_data.state.get_time();
1933                                    let mut rng = rand::rng();
1934                                    let final_amount = 3 + usize::from(
1935                                        self.scheduler.heartbeats(Duration::from_millis(5)),
1936                                    );
1937                                    self.add_particles(
1938                                        scene_data.particles_chance,
1939                                        final_amount,
1940                                        || {
1941                                            Particle::new(
1942                                                Duration::from_millis(1000),
1943                                                time,
1944                                                ParticleMode::IceWhirlwind,
1945                                                interpolated
1946                                                    .pos
1947                                                    .map(|e| e + rng.random_range(-0.25..0.25)),
1948                                                scene_data,
1949                                            )
1950                                        },
1951                                    );
1952                                }
1953                            },
1954                            states::rapid_melee::FrontendSpecifier::ElephantVacuum => {
1955                                if matches!(c.stage_section, StageSection::Action) {
1956                                    let time = scene_data.state.get_time();
1957                                    let mut rng = rand::rng();
1958
1959                                    let (end_radius, max_range) =
1960                                        if let CharacterState::RapidMelee(data) = character_state {
1961                                            let max_range =
1962                                                data.static_data.melee_constructor.range;
1963                                            (
1964                                                max_range
1965                                                    * (data.static_data.melee_constructor.angle
1966                                                        / 2.0
1967                                                        * PI
1968                                                        / 180.0)
1969                                                        .tan(),
1970                                                max_range,
1971                                            )
1972                                        } else {
1973                                            (0.0, 0.0)
1974                                        };
1975                                    let ori = ori.look_vec();
1976                                    let body_radius = body.max_radius() * 1.4;
1977                                    let body_offsets_z = body.height() * 0.4;
1978                                    let beam_offsets = Vec3::new(
1979                                        body_radius * ori.x * 1.1,
1980                                        body_radius * ori.y * 1.1,
1981                                        body_offsets_z,
1982                                    );
1983
1984                                    let (from, to) = (Vec3::<f32>::unit_z(), ori);
1985                                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
1986
1987                                    let final_amount = 5 + usize::from(
1988                                        self.scheduler.heartbeats(Duration::from_millis(5)),
1989                                    );
1990                                    self.add_particles(
1991                                        scene_data.particles_chance,
1992                                        final_amount,
1993                                        || {
1994                                            let trunk_pos = interpolated.pos + beam_offsets;
1995
1996                                            let range = rng.random_range(0.05..=max_range);
1997                                            let radius = rng
1998                                                .random_range(0.0..=end_radius * range / max_range);
1999                                            let theta = rng.random_range(0.0..2.0 * PI);
2000
2001                                            Particle::new_directed(
2002                                                Duration::from_millis(300),
2003                                                time,
2004                                                ParticleMode::ElephantVacuum,
2005                                                trunk_pos
2006                                                    + m * Vec3::new(
2007                                                        radius * theta.cos(),
2008                                                        radius * theta.sin(),
2009                                                        range,
2010                                                    ),
2011                                                trunk_pos,
2012                                                scene_data,
2013                                            )
2014                                        },
2015                                    );
2016                                }
2017                            },
2018                        }
2019                    }
2020                },
2021                CharacterState::RapidRanged(repeater) => {
2022                    if let Some(specifier) = repeater.static_data.specifier {
2023                        match specifier {
2024                            states::rapid_ranged::FrontendSpecifier::FireRainPhoenix => {
2025                                // base, dark clouds
2026                                let final_amount = 2 * usize::from(
2027                                    self.scheduler.heartbeats(Duration::from_millis(25)),
2028                                );
2029                                self.add_particles(
2030                                    scene_data.particles_chance,
2031                                    final_amount,
2032                                    || {
2033                                        let rand_pos = {
2034                                            let theta = rng.random::<f32>() * TAU;
2035                                            let radius = repeater
2036                                                .static_data
2037                                                .options
2038                                                .offset
2039                                                .map(|offset| offset.radius)
2040                                                .unwrap_or_default()
2041                                                * rng.random::<f32>().sqrt();
2042                                            let x = radius * theta.sin();
2043                                            let y = radius * theta.cos();
2044                                            Vec2::new(x, y) + interpolated.pos.xy()
2045                                        };
2046                                        let pos1 = rand_pos.with_z(
2047                                            repeater
2048                                                .static_data
2049                                                .options
2050                                                .offset
2051                                                .map(|offset| offset.height)
2052                                                .unwrap_or_default()
2053                                                + interpolated.pos.z
2054                                                + 2.0 * rng.random::<f32>(),
2055                                        );
2056                                        Particle::new_directed(
2057                                            Duration::from_secs_f32(3.0),
2058                                            time,
2059                                            ParticleMode::PhoenixCloud,
2060                                            pos1,
2061                                            pos1 + Vec3::new(7.09, 4.09, 18.09),
2062                                            scene_data,
2063                                        )
2064                                    },
2065                                );
2066                                let final_amount = 2 * usize::from(
2067                                    self.scheduler.heartbeats(Duration::from_millis(25)),
2068                                );
2069                                self.add_particles(
2070                                    scene_data.particles_chance,
2071                                    final_amount,
2072                                    || {
2073                                        let rand_pos = {
2074                                            let theta = rng.random::<f32>() * TAU;
2075                                            let radius = repeater
2076                                                .static_data
2077                                                .options
2078                                                .offset
2079                                                .map(|offset| offset.radius)
2080                                                .unwrap_or_default()
2081                                                * rng.random::<f32>().sqrt();
2082                                            let x = radius * theta.sin();
2083                                            let y = radius * theta.cos();
2084                                            Vec2::new(x, y) + interpolated.pos.xy()
2085                                        };
2086                                        let pos1 = rand_pos.with_z(
2087                                            repeater
2088                                                .static_data
2089                                                .options
2090                                                .offset
2091                                                .map(|offset| offset.height)
2092                                                .unwrap_or_default()
2093                                                + interpolated.pos.z
2094                                                + 1.5 * rng.random::<f32>(),
2095                                        );
2096                                        Particle::new_directed(
2097                                            Duration::from_secs_f32(2.5),
2098                                            time,
2099                                            ParticleMode::PhoenixCloud,
2100                                            pos1,
2101                                            pos1 + Vec3::new(10.025, 4.025, 17.025),
2102                                            scene_data,
2103                                        )
2104                                    },
2105                                );
2106                            },
2107                            states::rapid_ranged::FrontendSpecifier::PyroclasmCharge {
2108                                height: z,
2109                                radius: r,
2110                            } => {
2111                                const TAIL_SECS: f32 = 1.0;
2112                                match repeater.stage_section {
2113                                    StageSection::Buildup => {
2114                                        let progress = (repeater.timer.as_secs_f32()
2115                                            / repeater.static_data.buildup_duration.as_secs_f32())
2116                                        .clamp(0.0, 1.0)
2117                                            * 0.9;
2118                                        self.maintain_pyroclasm_charge_particles(
2119                                            scene_data,
2120                                            interpolated.pos,
2121                                            progress,
2122                                            z,
2123                                            r,
2124                                        );
2125                                    },
2126                                    StageSection::Action
2127                                        if repeater.timer.as_secs_f32() < TAIL_SECS =>
2128                                    {
2129                                        self.maintain_pyroclasm_charge_particles(
2130                                            scene_data,
2131                                            interpolated.pos,
2132                                            0.9,
2133                                            z,
2134                                            r,
2135                                        );
2136                                    },
2137                                    _ => {},
2138                                }
2139                            },
2140                        }
2141                    }
2142                },
2143                CharacterState::Blink(c) => {
2144                    if let Some(specifier) = c.static_data.frontend_specifier {
2145                        match specifier {
2146                            states::blink::FrontendSpecifier::CultistFlame => {
2147                                let final_amount = usize::from(
2148                                    self.scheduler.heartbeats(Duration::from_millis(10)),
2149                                );
2150                                self.add_particles(
2151                                    scene_data.particles_chance,
2152                                    final_amount,
2153                                    || {
2154                                        let center_pos =
2155                                            interpolated.pos + Vec3::unit_z() * body.height() / 2.0;
2156                                        let outer_pos = interpolated.pos
2157                                            + Vec3::new(
2158                                                2.0 * rng.random::<f32>() - 1.0,
2159                                                2.0 * rng.random::<f32>() - 1.0,
2160                                                0.0,
2161                                            )
2162                                            .normalized()
2163                                                * (body.max_radius() + 2.0)
2164                                            + Vec3::unit_z() * body.height() * rng.random::<f32>();
2165
2166                                        let (start_pos, end_pos) =
2167                                            if matches!(c.stage_section, StageSection::Buildup) {
2168                                                (outer_pos, center_pos)
2169                                            } else {
2170                                                (center_pos, outer_pos)
2171                                            };
2172
2173                                        Particle::new_directed(
2174                                            Duration::from_secs_f32(0.5),
2175                                            time,
2176                                            ParticleMode::CultistFlame,
2177                                            start_pos,
2178                                            end_pos,
2179                                            scene_data,
2180                                        )
2181                                    },
2182                                );
2183                            },
2184                            states::blink::FrontendSpecifier::FlameThrower => {
2185                                let final_amount = usize::from(
2186                                    self.scheduler.heartbeats(Duration::from_millis(10)),
2187                                );
2188                                self.add_particles(
2189                                    scene_data.particles_chance,
2190                                    final_amount,
2191                                    || {
2192                                        let center_pos =
2193                                            interpolated.pos + Vec3::unit_z() * body.height() / 2.0;
2194                                        let outer_pos = interpolated.pos
2195                                            + Vec3::new(
2196                                                2.0 * rng.random::<f32>() - 1.0,
2197                                                2.0 * rng.random::<f32>() - 1.0,
2198                                                0.0,
2199                                            )
2200                                            .normalized()
2201                                                * (body.max_radius() + 2.0)
2202                                            + Vec3::unit_z() * body.height() * rng.random::<f32>();
2203
2204                                        let (start_pos, end_pos) =
2205                                            if matches!(c.stage_section, StageSection::Buildup) {
2206                                                (outer_pos, center_pos)
2207                                            } else {
2208                                                (center_pos, outer_pos)
2209                                            };
2210
2211                                        Particle::new_directed(
2212                                            Duration::from_secs_f32(0.5),
2213                                            time,
2214                                            ParticleMode::FlameThrower,
2215                                            start_pos,
2216                                            end_pos,
2217                                            scene_data,
2218                                        )
2219                                    },
2220                                );
2221                            },
2222                        }
2223                    }
2224                },
2225                CharacterState::SelfBuff(c) => {
2226                    if let Some(specifier) = c.static_data.specifier {
2227                        match specifier {
2228                            states::self_buff::FrontendSpecifier::FromTheAshes => {
2229                                if matches!(c.stage_section, StageSection::Action) {
2230                                    let pos = interpolated.pos;
2231                                    let final_amount = 2 * usize::from(
2232                                        self.scheduler.heartbeats(Duration::from_millis(1)),
2233                                    );
2234                                    self.add_particles(
2235                                        scene_data.particles_chance,
2236                                        final_amount,
2237                                        || {
2238                                            let start_pos = pos + Vec3::unit_z() - 1.0;
2239                                            let end_pos = pos
2240                                                + Vec3::new(
2241                                                    4.0 * rng.random::<f32>() - 1.0,
2242                                                    4.0 * rng.random::<f32>() - 1.0,
2243                                                    0.0,
2244                                                )
2245                                                .normalized()
2246                                                    * 1.5
2247                                                + Vec3::unit_z()
2248                                                + 5.0 * rng.random::<f32>();
2249
2250                                            Particle::new_directed(
2251                                                Duration::from_secs_f32(0.5),
2252                                                time,
2253                                                ParticleMode::FieryBurst,
2254                                                start_pos,
2255                                                end_pos,
2256                                                scene_data,
2257                                            )
2258                                        },
2259                                    );
2260                                    let final_amount = usize::from(
2261                                        self.scheduler.heartbeats(Duration::from_millis(10)),
2262                                    );
2263                                    self.add_particles(
2264                                        scene_data.particles_chance,
2265                                        final_amount,
2266                                        || {
2267                                            Particle::new(
2268                                                Duration::from_millis(650),
2269                                                time,
2270                                                ParticleMode::FieryBurstVortex,
2271                                                pos.map(|e| e + rng.random_range(-0.25..0.25))
2272                                                    + Vec3::new(0.0, 0.0, 1.0),
2273                                                scene_data,
2274                                            )
2275                                        },
2276                                    );
2277                                    let final_amount = usize::from(
2278                                        self.scheduler.heartbeats(Duration::from_millis(40)),
2279                                    );
2280                                    self.add_particles(
2281                                        scene_data.particles_chance,
2282                                        final_amount,
2283                                        || {
2284                                            Particle::new(
2285                                                Duration::from_millis(1000),
2286                                                time,
2287                                                ParticleMode::FieryBurstSparks,
2288                                                pos.map(|e| e + rng.random_range(-0.25..0.25)),
2289                                                scene_data,
2290                                            )
2291                                        },
2292                                    );
2293                                    let final_amount = usize::from(
2294                                        self.scheduler.heartbeats(Duration::from_millis(14)),
2295                                    );
2296                                    self.add_particles(
2297                                        scene_data.particles_chance,
2298                                        final_amount,
2299                                        || {
2300                                            let pos1 =
2301                                                pos.map(|e| e + rng.random_range(-0.25..0.25));
2302                                            Particle::new_directed(
2303                                                Duration::from_millis(1000),
2304                                                time,
2305                                                ParticleMode::FieryBurstAsh,
2306                                                pos1,
2307                                                Vec3::new(
2308                                                    4.5,    // radius of rand spawn
2309                                                    20.4,   // integer part - radius of the curve part, fractional part - relative time of setting particle on fire
2310                                                    8.58)   // height of the flight
2311                                                    + pos1,
2312                                                scene_data,
2313                                            )
2314                                        },
2315                                    );
2316                                }
2317                            },
2318                        }
2319                    }
2320                    use buff::BuffKind;
2321                    if c.static_data
2322                        .buffs
2323                        .iter()
2324                        .any(|buff_desc| matches!(buff_desc.kind, BuffKind::Frenzied))
2325                        && matches!(c.stage_section, StageSection::Action)
2326                    {
2327                        let final_amount =
2328                            usize::from(self.scheduler.heartbeats(Duration::from_millis(5)));
2329                        self.add_particles(scene_data.particles_chance, final_amount, || {
2330                            let start_pos = interpolated.pos
2331                                + Vec3::new(
2332                                    body.max_radius(),
2333                                    body.max_radius(),
2334                                    body.height() / 2.0,
2335                                )
2336                                .map(|d| d * rng.random_range(-1.0..1.0));
2337                            let end_pos = interpolated.pos + (start_pos - interpolated.pos) * 6.0;
2338                            Particle::new_directed(
2339                                Duration::from_secs(1),
2340                                time,
2341                                ParticleMode::Enraged,
2342                                start_pos,
2343                                end_pos,
2344                                scene_data,
2345                            )
2346                        });
2347                    }
2348                },
2349                CharacterState::BasicBeam(beam) => {
2350                    let ori = *ori;
2351                    let _look_dir = *character_activity.look_dir.unwrap_or(ori.look_dir());
2352                    let dir = ori.look_dir(); //.with_z(look_dir.z);
2353                    let specifier = beam.static_data.specifier;
2354                    if specifier == beam::FrontendSpecifier::PhoenixLaser
2355                        && matches!(beam.stage_section, StageSection::Buildup)
2356                    {
2357                        let final_amount =
2358                            2 * usize::from(self.scheduler.heartbeats(Duration::from_millis(2)));
2359                        self.add_particles(scene_data.particles_chance, final_amount, || {
2360                            let mut left_right_alignment =
2361                                dir.cross(Vec3::new(0.0, 0.0, 1.0)).normalized();
2362                            if rng.random_bool(0.5) {
2363                                left_right_alignment *= -1.0;
2364                            }
2365                            let start = interpolated.pos
2366                                + left_right_alignment * 4.0
2367                                + dir.normalized() * 6.0;
2368                            let lifespan = Duration::from_secs_f32(0.5);
2369                            Particle::new_directed(
2370                                lifespan,
2371                                time,
2372                                ParticleMode::PhoenixBuildUpAim,
2373                                start,
2374                                interpolated.pos
2375                                    + dir.normalized() * 3.0
2376                                    + left_right_alignment * 0.4
2377                                    + vel.map_or(Vec3::zero(), |v| v.0 * lifespan.as_secs_f32()),
2378                                scene_data,
2379                            )
2380                        });
2381                    }
2382                },
2383                CharacterState::Glide(glide) => {
2384                    if let Some(Fluid::Air {
2385                        vel: air_vel,
2386                        elevation: _,
2387                    }) = physics.in_fluid
2388                    {
2389                        // Empirical observation is that air_vel is somewhere
2390                        // between 0.0 and 13.0, but we are extending to be sure
2391                        const MAX_AIR_VEL: f32 = 15.0;
2392                        const MIN_AIR_VEL: f32 = -2.0;
2393
2394                        let minmax_norm = |val, min, max| (val - min) / (max - min);
2395
2396                        let wind_speed = air_vel.0.magnitude();
2397
2398                        // Less means more frequent particles
2399                        let heartbeat = 200
2400                            - Lerp::lerp(
2401                                50u64,
2402                                150,
2403                                minmax_norm(wind_speed, MIN_AIR_VEL, MAX_AIR_VEL),
2404                            );
2405
2406                        let new_count = usize::from(
2407                            self.scheduler.heartbeats(Duration::from_millis(heartbeat)),
2408                        );
2409
2410                        // More number, longer particles
2411                        let duration = Lerp::lerp(
2412                            0u64,
2413                            1000,
2414                            minmax_norm(wind_speed, MIN_AIR_VEL, MAX_AIR_VEL),
2415                        );
2416                        let duration = Duration::from_millis(duration);
2417
2418                        self.add_particles(scene_data.particles_chance, new_count, || {
2419                            let start_pos = interpolated.pos
2420                                + Vec3::new(
2421                                    body.max_radius(),
2422                                    body.max_radius(),
2423                                    body.height() / 2.0,
2424                                )
2425                                .map(|d| d * rng.random_range(-10.0..10.0));
2426
2427                            Particle::new_directed(
2428                                duration,
2429                                time,
2430                                ParticleMode::Airflow,
2431                                start_pos,
2432                                start_pos + air_vel.0,
2433                                scene_data,
2434                            )
2435                        });
2436
2437                        // When using the glide boost, emit particles
2438                        if let Some(states::glide::Boost::Forward(_)) = &glide.booster
2439                            && let Some(figure_state) =
2440                                figure_mgr.states.character_states.get(&entity)
2441                            && let Some(tp0) = figure_state.primary_abs_trail_points
2442                            && let Some(tp1) = figure_state.secondary_abs_trail_points
2443                        {
2444                            for _ in 0..self.scheduler.heartbeats(Duration::from_millis(5)) {
2445                                self.push_particle(scene_data.particles_chance, Particle::new(
2446                                    Duration::from_secs(2),
2447                                    time,
2448                                    ParticleMode::EngineJet,
2449                                    ((tp0.0 + tp1.1) * 0.5)
2450                                        // TODO: This offset is used to position the particles at the engine outlet. Ideally, we'd have a way to configure this per-glider
2451                                        + Vec3::unit_z() * 0.5
2452                                        + Vec3::<f32>::zero().map(|_| rng.random_range(-0.25..0.25))
2453                                        + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.random::<f32>()),
2454                                scene_data,
2455                                ));
2456                            }
2457                        }
2458                    }
2459                },
2460                CharacterState::Transform(data) => {
2461                    if matches!(data.stage_section, StageSection::Buildup)
2462                        && let Some(specifier) = data.static_data.specifier
2463                    {
2464                        match specifier {
2465                            states::transform::FrontendSpecifier::Evolve => {
2466                                let final_amount = usize::from(
2467                                    self.scheduler.heartbeats(Duration::from_millis(10)),
2468                                );
2469                                self.add_particles(
2470                                    scene_data.particles_chance,
2471                                    final_amount,
2472                                    || {
2473                                        let start_pos = interpolated.pos
2474                                            + (Vec2::unit_y()
2475                                                * rng.random::<f32>()
2476                                                * body.max_radius())
2477                                            .rotated_z(rng.random_range(0.0..(PI * 2.0)))
2478                                            .with_z(body.height() * rng.random::<f32>());
2479
2480                                        Particle::new_directed(
2481                                            Duration::from_millis(100),
2482                                            time,
2483                                            ParticleMode::BarrelOrgan,
2484                                            start_pos,
2485                                            start_pos + Vec3::unit_z() * 2.0,
2486                                            scene_data,
2487                                        )
2488                                    },
2489                                )
2490                            },
2491                            states::transform::FrontendSpecifier::Cursekeeper => {
2492                                let final_amount = usize::from(
2493                                    self.scheduler.heartbeats(Duration::from_millis(10)),
2494                                );
2495                                self.add_particles(
2496                                    scene_data.particles_chance,
2497                                    final_amount,
2498                                    || {
2499                                        let start_pos = interpolated.pos
2500                                            + (Vec2::unit_y()
2501                                                * rng.random::<f32>()
2502                                                * body.max_radius())
2503                                            .rotated_z(rng.random_range(0.0..(PI * 2.0)))
2504                                            .with_z(body.height() * rng.random::<f32>());
2505
2506                                        Particle::new_directed(
2507                                            Duration::from_millis(100),
2508                                            time,
2509                                            ParticleMode::FireworkPurple,
2510                                            start_pos,
2511                                            start_pos + Vec3::unit_z() * 2.0,
2512                                            scene_data,
2513                                        )
2514                                    },
2515                                )
2516                            },
2517                        }
2518                    }
2519                },
2520                CharacterState::ChargedMelee(_melee) => {
2521                    self.maintain_hydra_tail_swipe_particles(
2522                        scene_data,
2523                        figure_mgr,
2524                        entity,
2525                        interpolated.pos,
2526                        body,
2527                        character_state,
2528                        inventory,
2529                    );
2530                },
2531                CharacterState::DashMelee(s) => {
2532                    if matches!(s.stage_section, StageSection::Charge) {
2533                        match s.static_data.frontend_specifier {
2534                            Some(states::dash_melee::FrontendSpecifier::FireDash) => {
2535                                let look_dir = ori.to_horizontal().look_dir().to_vec();
2536                                let back_dir = -look_dir;
2537                                let time = scene_data.state.get_time();
2538                                let mut rng = rand::rng();
2539                                let heartbeats =
2540                                    self.scheduler.heartbeats(Duration::from_millis(5));
2541                                let pos = interpolated.pos + Vec3::unit_z() * 0.9;
2542
2543                                // Rotation matrix to align the Z-axis cone with back_dir
2544                                let m = Mat3::<f32>::rotation_from_to_3d(
2545                                    Vec3::<f32>::unit_z(),
2546                                    back_dir,
2547                                );
2548
2549                                let tail_angle: f32 = 0.4;
2550                                let tail_range = 3.5_f32;
2551                                self.add_particles(
2552                                    scene_data.particles_chance,
2553                                    usize::from(heartbeats) * 3,
2554                                    || {
2555                                        let phi = rng.random_range(0.0..tail_angle);
2556                                        let theta = rng.random_range(0.0..TAU);
2557                                        let offset_z = Vec3::new(
2558                                            phi.sin() * theta.cos(),
2559                                            phi.sin() * theta.sin(),
2560                                            phi.cos(),
2561                                        );
2562                                        let dir = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2563                                        let mode = if rng.random_bool(0.5) {
2564                                            ParticleMode::FlamethrowerBlue
2565                                        } else {
2566                                            ParticleMode::FlameThrower
2567                                        };
2568                                        Particle::new_directed(
2569                                            Duration::from_millis(300),
2570                                            time,
2571                                            mode,
2572                                            pos,
2573                                            pos + dir * tail_range,
2574                                            scene_data,
2575                                        )
2576                                    },
2577                                );
2578
2579                                let nose_pos = pos + look_dir * 3.0;
2580                                let nose_angle: f32 = 2.8; //
2581                                let nose_range = 3.0_f32;
2582                                self.add_particles(
2583                                    scene_data.particles_chance,
2584                                    usize::from(heartbeats) * 6,
2585                                    || {
2586                                        let phi = rng.random_range(0.0..nose_angle);
2587                                        let theta = rng.random_range(0.0..TAU);
2588                                        let offset_z = Vec3::new(
2589                                            phi.sin() * theta.cos(),
2590                                            phi.sin() * theta.sin(),
2591                                            phi.cos(),
2592                                        );
2593                                        let dir = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2594                                        Particle::new_directed(
2595                                            Duration::from_millis(180),
2596                                            time,
2597                                            ParticleMode::FlameThrower,
2598                                            nose_pos,
2599                                            nose_pos + dir * nose_range,
2600                                            scene_data,
2601                                        )
2602                                    },
2603                                );
2604                            },
2605                            None => (),
2606                        }
2607                    }
2608                },
2609                _ => {},
2610            }
2611        }
2612    }
2613
2614    fn maintain_beam_particles(&mut self, scene_data: &SceneData, lights: &mut Vec<Light>) {
2615        let state = scene_data.state;
2616        let ecs = state.ecs();
2617        let time = state.get_time();
2618        let terrain = state.terrain();
2619        // Limit to 100 per tick, so at less than 10 FPS particle generation
2620        // work doesn't increase frame cost further.
2621        let tick_elapse = u32::from(self.scheduler.heartbeats(Duration::from_millis(1)).min(100));
2622        let mut rng = rand::rng();
2623
2624        for (beam, ori) in (&ecs.read_storage::<Beam>(), &ecs.read_storage::<Ori>()).join() {
2625            let particles_per_sec = (match beam.specifier {
2626                beam::FrontendSpecifier::Flamethrower
2627                | beam::FrontendSpecifier::Bubbles
2628                | beam::FrontendSpecifier::Steam
2629                | beam::FrontendSpecifier::Frost
2630                | beam::FrontendSpecifier::Poison
2631                | beam::FrontendSpecifier::Ink
2632                | beam::FrontendSpecifier::PhoenixLaser
2633                | beam::FrontendSpecifier::Gravewarden => 300.0,
2634                beam::FrontendSpecifier::FirePillar | beam::FrontendSpecifier::FlameWallPillar => {
2635                    40.0 * beam.end_radius.powi(2)
2636                },
2637                beam::FrontendSpecifier::LifestealBeam => 420.0,
2638                beam::FrontendSpecifier::Cultist => 960.0,
2639                beam::FrontendSpecifier::WebStrand => 180.0,
2640                beam::FrontendSpecifier::Lightning => 120.0,
2641                beam::FrontendSpecifier::FireGigasOverheat => 1600.0,
2642            }) / 1000.0;
2643
2644            let beam_tick_count = tick_elapse as f32 * particles_per_sec;
2645            let beam_tick_count = if rng.random_bool(f64::from(beam_tick_count.fract())) {
2646                beam_tick_count.ceil() as u32
2647            } else {
2648                beam_tick_count.floor() as u32
2649            };
2650
2651            if beam_tick_count == 0 {
2652                continue;
2653            }
2654
2655            let distributed_time = tick_elapse as f64 / (beam_tick_count * 1000) as f64;
2656            let angle = (beam.end_radius / beam.range).atan();
2657            let beam_dir = (beam.bezier.ctrl - beam.bezier.start)
2658                .try_normalized()
2659                .unwrap_or(*ori.look_dir());
2660            let raycast_distance = |from, to| terrain.ray(from, to).until(Block::is_solid).cast().0;
2661
2662            match beam.specifier {
2663                beam::FrontendSpecifier::Flamethrower => {
2664                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
2665                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
2666                    // Emit a light when using flames
2667                    if scene_data.flashing_lights_enabled {
2668                        lights.push(Light::new(
2669                            beam.bezier.start,
2670                            Rgb::new(1.0, 0.25, 0.05).map(|e| e * rng.random_range(0.8..1.2)),
2671                            2.0,
2672                        ));
2673                    }
2674
2675                    for i in 0..beam_tick_count {
2676                        let phi: f32 = rng.random_range(0.0..angle);
2677                        let theta: f32 = rng.random_range(0.0..2.0 * PI);
2678                        let offset_z =
2679                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
2680                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2681                        self.push_particle(
2682                            scene_data.particles_chance,
2683                            Particle::new_directed_with_collision(
2684                                Duration::from_secs_f64(beam.duration.0),
2685                                time + distributed_time * i as f64,
2686                                ParticleMode::FlameThrower,
2687                                beam.bezier.start,
2688                                beam.bezier.start + random_ori * beam.range,
2689                                scene_data,
2690                                raycast_distance,
2691                            ),
2692                        );
2693                    }
2694                },
2695                beam::FrontendSpecifier::FireGigasOverheat => {
2696                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
2697                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
2698                    // Emit a light when using flames
2699                    if scene_data.flashing_lights_enabled {
2700                        lights.push(Light::new(
2701                            beam.bezier.start,
2702                            Rgb::new(1.0, 0.25, 0.05).map(|e| e * rng.random_range(0.8..1.2)),
2703                            2.0,
2704                        ));
2705                    }
2706
2707                    for i in 0..beam_tick_count {
2708                        let phi: f32 = rng.random_range(0.0..angle);
2709                        let theta: f32 = rng.random_range(0.0..2.0 * PI);
2710                        let offset_z =
2711                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
2712                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2713                        self.push_particle(
2714                            scene_data.particles_chance,
2715                            Particle::new_directed_with_collision(
2716                                Duration::from_secs_f64(beam.duration.0),
2717                                time + distributed_time * i as f64,
2718                                ParticleMode::FireGigasOverheat,
2719                                beam.bezier.start,
2720                                beam.bezier.start + random_ori * beam.range,
2721                                scene_data,
2722                                raycast_distance,
2723                            ),
2724                        );
2725                    }
2726                },
2727                beam::FrontendSpecifier::FirePillar | beam::FrontendSpecifier::FlameWallPillar => {
2728                    // Emit a light when using flames
2729                    if scene_data.flashing_lights_enabled {
2730                        lights.push(Light::new(
2731                            beam.bezier.start,
2732                            Rgb::new(1.0, 0.25, 0.05).map(|e| e * rng.random_range(0.8..1.2)),
2733                            2.0,
2734                        ));
2735                    }
2736
2737                    for i in 0..beam_tick_count {
2738                        let theta: f32 = rng.random_range(0.0..2.0 * PI);
2739                        let radius = beam.start_radius * (1.0 - rng.random::<f32>().powi(8));
2740                        let offset = Vec3::new(radius * theta.cos(), radius * theta.sin(), 0.0);
2741                        self.push_particle(
2742                            scene_data.particles_chance,
2743                            Particle::new_directed_with_collision(
2744                                Duration::from_secs_f64(beam.duration.0),
2745                                time + distributed_time * i as f64,
2746                                ParticleMode::FirePillar,
2747                                beam.bezier.start + offset,
2748                                beam.bezier.start + offset + beam.range * Vec3::unit_z(),
2749                                scene_data,
2750                                raycast_distance,
2751                            ),
2752                        );
2753                    }
2754                },
2755                beam::FrontendSpecifier::Cultist => {
2756                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
2757                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
2758                    // Emit a light when using flames
2759                    if scene_data.flashing_lights_enabled {
2760                        lights.push(Light::new(
2761                            beam.bezier.start,
2762                            Rgb::new(1.0, 0.0, 1.0).map(|e| e * rng.random_range(0.5..1.0)),
2763                            2.0,
2764                        ));
2765                    }
2766                    for i in 0..beam_tick_count {
2767                        let phi: f32 = rng.random_range(0.0..angle);
2768                        let theta: f32 = rng.random_range(0.0..2.0 * PI);
2769                        let offset_z =
2770                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
2771                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2772                        self.push_particle(
2773                            scene_data.particles_chance,
2774                            Particle::new_directed_with_collision(
2775                                Duration::from_secs_f64(beam.duration.0),
2776                                time + distributed_time * i as f64,
2777                                ParticleMode::CultistFlame,
2778                                beam.bezier.start,
2779                                beam.bezier.start + random_ori * beam.range,
2780                                scene_data,
2781                                raycast_distance,
2782                            ),
2783                        );
2784                    }
2785                },
2786                beam::FrontendSpecifier::LifestealBeam => {
2787                    // Emit a light when using lifesteal beam
2788                    if scene_data.flashing_lights_enabled {
2789                        lights.push(Light::new(beam.bezier.start, Rgb::new(0.8, 1.0, 0.5), 1.0));
2790                    }
2791
2792                    // If the beam is one straight line, we can run raycast one time.
2793                    let bezier_end = beam.bezier.start + beam_dir * beam.range;
2794                    let distance = raycast_distance(beam.bezier.start, bezier_end);
2795                    for i in 0..beam_tick_count {
2796                        self.push_particle(
2797                            scene_data.particles_chance,
2798                            Particle::new_directed_with_collision(
2799                                Duration::from_secs_f64(beam.duration.0),
2800                                time + distributed_time * i as f64,
2801                                ParticleMode::LifestealBeam,
2802                                beam.bezier.start,
2803                                bezier_end,
2804                                scene_data,
2805                                |_from, _to| distance,
2806                            ),
2807                        );
2808                    }
2809                },
2810                beam::FrontendSpecifier::Gravewarden => {
2811                    for i in 0..beam_tick_count {
2812                        let mut offset = 0.5;
2813                        let side = Vec2::new(-beam_dir.y, beam_dir.x);
2814                        self.add_particles(scene_data.particles_chance, 2, || {
2815                            offset = -offset;
2816                            Particle::new_directed_with_collision(
2817                                Duration::from_secs_f64(beam.duration.0),
2818                                time + distributed_time * i as f64,
2819                                ParticleMode::Laser,
2820                                beam.bezier.start + beam_dir * 1.5 + side * offset,
2821                                beam.bezier.start + beam_dir * beam.range + side * offset,
2822                                scene_data,
2823                                raycast_distance,
2824                            )
2825                        });
2826                    }
2827                },
2828                beam::FrontendSpecifier::WebStrand => {
2829                    let bezier_end = beam.bezier.start + beam_dir * beam.range;
2830                    let distance = raycast_distance(beam.bezier.start, bezier_end);
2831                    for i in 0..beam_tick_count {
2832                        self.push_particle(
2833                            scene_data.particles_chance,
2834                            Particle::new_directed_with_collision(
2835                                Duration::from_secs_f64(beam.duration.0),
2836                                time + distributed_time * i as f64,
2837                                ParticleMode::WebStrand,
2838                                beam.bezier.start,
2839                                bezier_end,
2840                                scene_data,
2841                                |_from, _to| distance,
2842                            ),
2843                        );
2844                    }
2845                },
2846                beam::FrontendSpecifier::Bubbles => {
2847                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
2848                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
2849                    for i in 0..beam_tick_count {
2850                        let phi: f32 = rng.random_range(0.0..angle);
2851                        let theta: f32 = rng.random_range(0.0..2.0 * PI);
2852                        let offset_z =
2853                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
2854                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2855                        self.push_particle(
2856                            scene_data.particles_chance,
2857                            Particle::new_directed_with_collision(
2858                                Duration::from_secs_f64(beam.duration.0),
2859                                time + distributed_time * i as f64,
2860                                ParticleMode::Bubbles,
2861                                beam.bezier.start,
2862                                beam.bezier.start + random_ori * beam.range,
2863                                scene_data,
2864                                raycast_distance,
2865                            ),
2866                        );
2867                    }
2868                },
2869                beam::FrontendSpecifier::Poison => {
2870                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
2871                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
2872                    for i in 0..beam_tick_count {
2873                        let phi: f32 = rng.random_range(0.0..angle);
2874                        let theta: f32 = rng.random_range(0.0..2.0 * PI);
2875                        let offset_z =
2876                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
2877                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2878                        self.push_particle(
2879                            scene_data.particles_chance,
2880                            Particle::new_directed_with_collision(
2881                                Duration::from_secs_f64(beam.duration.0),
2882                                time + distributed_time * i as f64,
2883                                ParticleMode::Poison,
2884                                beam.bezier.start,
2885                                beam.bezier.start + random_ori * beam.range,
2886                                scene_data,
2887                                raycast_distance,
2888                            ),
2889                        );
2890                    }
2891                },
2892                beam::FrontendSpecifier::Ink => {
2893                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
2894                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
2895                    for i in 0..beam_tick_count {
2896                        let phi: f32 = rng.random_range(0.0..angle);
2897                        let theta: f32 = rng.random_range(0.0..2.0 * PI);
2898                        let offset_z =
2899                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
2900                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2901                        self.push_particle(
2902                            scene_data.particles_chance,
2903                            Particle::new_directed_with_collision(
2904                                Duration::from_secs_f64(beam.duration.0),
2905                                time + distributed_time * i as f64,
2906                                ParticleMode::Bubbles,
2907                                beam.bezier.start,
2908                                beam.bezier.start + random_ori * beam.range,
2909                                scene_data,
2910                                raycast_distance,
2911                            ),
2912                        );
2913                    }
2914                },
2915                beam::FrontendSpecifier::Steam => {
2916                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
2917                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
2918                    for i in 0..beam_tick_count {
2919                        let phi: f32 = rng.random_range(0.0..angle);
2920                        let theta: f32 = rng.random_range(0.0..2.0 * PI);
2921                        let offset_z =
2922                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
2923                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2924                        self.push_particle(
2925                            scene_data.particles_chance,
2926                            Particle::new_directed_with_collision(
2927                                Duration::from_secs_f64(beam.duration.0),
2928                                time + distributed_time * i as f64,
2929                                ParticleMode::Steam,
2930                                beam.bezier.start,
2931                                beam.bezier.start + random_ori * beam.range,
2932                                scene_data,
2933                                raycast_distance,
2934                            ),
2935                        );
2936                    }
2937                },
2938                beam::FrontendSpecifier::Lightning => {
2939                    let bezier_end = beam.bezier.start + beam_dir * beam.range;
2940                    let distance = raycast_distance(beam.bezier.start, bezier_end);
2941                    for i in 0..beam_tick_count {
2942                        self.push_particle(
2943                            scene_data.particles_chance,
2944                            Particle::new_directed_with_collision(
2945                                Duration::from_secs_f64(beam.duration.0),
2946                                time + distributed_time * i as f64,
2947                                ParticleMode::Lightning,
2948                                beam.bezier.start,
2949                                bezier_end,
2950                                scene_data,
2951                                |_from, _to| distance,
2952                            ),
2953                        );
2954                    }
2955                },
2956                beam::FrontendSpecifier::Frost => {
2957                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
2958                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
2959                    for i in 0..beam_tick_count {
2960                        let phi: f32 = rng.random_range(0.0..angle);
2961                        let theta: f32 = rng.random_range(0.0..2.0 * PI);
2962                        let offset_z =
2963                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
2964                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2965                        self.push_particle(
2966                            scene_data.particles_chance,
2967                            Particle::new_directed_with_collision(
2968                                Duration::from_secs_f64(beam.duration.0),
2969                                time + distributed_time * i as f64,
2970                                ParticleMode::Ice,
2971                                beam.bezier.start,
2972                                beam.bezier.start + random_ori * beam.range,
2973                                scene_data,
2974                                raycast_distance,
2975                            ),
2976                        );
2977                    }
2978                },
2979                beam::FrontendSpecifier::PhoenixLaser => {
2980                    let bezier_end = beam.bezier.start + beam_dir * beam.range;
2981                    let distance = raycast_distance(beam.bezier.start, bezier_end);
2982                    for i in 0..beam_tick_count {
2983                        self.push_particle(
2984                            scene_data.particles_chance,
2985                            Particle::new_directed_with_collision(
2986                                Duration::from_secs_f64(beam.duration.0),
2987                                time + distributed_time * i as f64,
2988                                ParticleMode::PhoenixBeam,
2989                                beam.bezier.start,
2990                                bezier_end,
2991                                scene_data,
2992                                |_from, _to| distance,
2993                            ),
2994                        );
2995                    }
2996                },
2997            }
2998        }
2999    }
3000
3001    fn maintain_aura_particles(&mut self, scene_data: &SceneData) {
3002        let state = scene_data.state;
3003        let ecs = state.ecs();
3004        let time = state.get_time();
3005        let mut rng = rand::rng();
3006        let dt = scene_data.state.get_delta_time();
3007
3008        for (interp, pos, auras, body_maybe) in (
3009            ecs.read_storage::<Interpolated>().maybe(),
3010            &ecs.read_storage::<Pos>(),
3011            &ecs.read_storage::<comp::Auras>(),
3012            ecs.read_storage::<comp::Body>().maybe(),
3013        )
3014            .join()
3015        {
3016            let pos = interp.map_or(pos.0, |i| i.pos);
3017
3018            for (_, aura) in auras.auras.iter() {
3019                match aura.aura_kind {
3020                    aura::AuraKind::Buff {
3021                        kind: buff::BuffKind::ProtectingWard,
3022                        ..
3023                    } => {
3024                        let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
3025                        self.add_particles(
3026                            scene_data.particles_chance,
3027                            aura.radius.powi(2) as usize * usize::from(heartbeats) / 300,
3028                            || {
3029                                let rand_dist = aura.radius * (1.0 - rng.random::<f32>().powi(100));
3030                                let init_pos = Vec3::new(rand_dist, 0_f32, 0_f32);
3031                                let duration = Duration::from_secs_f64(
3032                                    aura.end_time
3033                                        .map_or(1.0, |end| end.0 - time)
3034                                        .clamp(0.0, 1.0),
3035                                );
3036                                Particle::new_directed(
3037                                    duration,
3038                                    time,
3039                                    ParticleMode::EnergyNature,
3040                                    pos,
3041                                    pos + init_pos,
3042                                    scene_data,
3043                                )
3044                            },
3045                        );
3046                    },
3047                    aura::AuraKind::Buff {
3048                        kind: buff::BuffKind::Regeneration,
3049                        ..
3050                    } => {
3051                        if auras.auras.iter().any(|(_, aura)| {
3052                            matches!(aura.aura_kind, aura::AuraKind::Buff {
3053                                kind: buff::BuffKind::ProtectingWard,
3054                                ..
3055                            })
3056                        }) {
3057                            // If same entity has both protecting ward and regeneration auras, skip
3058                            // particles for regeneration
3059                            continue;
3060                        }
3061                        let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
3062                        self.add_particles(
3063                            scene_data.particles_chance,
3064                            aura.radius.powi(2) as usize * usize::from(heartbeats) / 300,
3065                            || {
3066                                let rand_dist = aura.radius * (1.0 - rng.random::<f32>().powi(100));
3067                                let init_pos = Vec3::new(rand_dist, 0_f32, 0_f32);
3068                                let duration = Duration::from_secs_f64(
3069                                    aura.end_time
3070                                        .map_or(1.0, |end| end.0 - time)
3071                                        .clamp(0.0, 1.0),
3072                                );
3073                                Particle::new_directed(
3074                                    duration,
3075                                    time,
3076                                    ParticleMode::EnergyHealing,
3077                                    pos,
3078                                    pos + init_pos,
3079                                    scene_data,
3080                                )
3081                            },
3082                        );
3083                    },
3084                    aura::AuraKind::Buff {
3085                        kind: buff::BuffKind::Burning,
3086                        ..
3087                    } => {
3088                        let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
3089                        match aura.frontend_specifier {
3090                            Some(aura::Specifier::FieryAura) => {
3091                                //Flame Cloak
3092                                self.add_particles(
3093                                    scene_data.particles_chance,
3094                                    aura.radius.powi(2) as usize * usize::from(heartbeats) / 3,
3095                                    || {
3096                                        let orbit_speed = 1.0_f32;
3097                                        let theta = time as f32 * orbit_speed
3098                                            + rng.random::<f32>() * std::f32::consts::TAU;
3099                                        let r = aura.radius * (0.25 + rng.random::<f32>() * 0.2);
3100                                        let spawn_pos =
3101                                            (Vec2::new(r * theta.sin(), r * theta.cos())
3102                                                + pos.xy())
3103                                            .with_z(pos.z + rng.random::<f32>() * 0.5);
3104                                        let duration = Duration::from_secs_f64(
3105                                            aura.end_time
3106                                                .map_or(0.3, |end| (end.0 - time).clamp(0.0, 0.3)),
3107                                        );
3108                                        Particle::new(
3109                                            duration,
3110                                            time,
3111                                            ParticleMode::FlameCloakOrbit,
3112                                            spawn_pos,
3113                                            scene_data,
3114                                        )
3115                                    },
3116                                );
3117                            },
3118                            None => {
3119                                //Gnarling Totem Red
3120                                self.add_particles(
3121                                    scene_data.particles_chance,
3122                                    aura.radius.powi(2) as usize * usize::from(heartbeats) / 300,
3123                                    || {
3124                                        let rand_pos = {
3125                                            let theta = rng.random::<f32>() * TAU;
3126                                            let radius = aura.radius * rng.random::<f32>().sqrt();
3127                                            let x = radius * theta.sin();
3128                                            let y = radius * theta.cos();
3129                                            Vec2::new(x, y) + pos.xy()
3130                                        };
3131                                        let duration = Duration::from_secs_f64(
3132                                            aura.end_time
3133                                                .map_or(1.0, |end| end.0 - time)
3134                                                .clamp(0.0, 1.0),
3135                                        );
3136                                        Particle::new_directed(
3137                                            duration,
3138                                            time,
3139                                            ParticleMode::FlameThrower,
3140                                            rand_pos.with_z(pos.z),
3141                                            rand_pos.with_z(pos.z + 1.0),
3142                                            scene_data,
3143                                        )
3144                                    },
3145                                );
3146                            },
3147                            _ => {},
3148                        }
3149                    },
3150                    aura::AuraKind::Buff {
3151                        kind: buff::BuffKind::Hastened,
3152                        ..
3153                    } => {
3154                        let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
3155                        self.add_particles(
3156                            scene_data.particles_chance,
3157                            aura.radius.powi(2) as usize * usize::from(heartbeats) / 300,
3158                            || {
3159                                let rand_dist = aura.radius * (1.0 - rng.random::<f32>().powi(100));
3160                                let init_pos = Vec3::new(rand_dist, 0_f32, 0_f32);
3161                                let duration = Duration::from_secs_f64(
3162                                    aura.end_time
3163                                        .map_or(1.0, |end| end.0 - time)
3164                                        .clamp(0.0, 1.0),
3165                                );
3166                                Particle::new_directed(
3167                                    duration,
3168                                    time,
3169                                    ParticleMode::EnergyBuffing,
3170                                    pos,
3171                                    pos + init_pos,
3172                                    scene_data,
3173                                )
3174                            },
3175                        );
3176                    },
3177                    aura::AuraKind::Buff {
3178                        kind: buff::BuffKind::Frozen,
3179                        ..
3180                    } => {
3181                        let is_new_aura = aura.data.duration.is_none_or(|max_dur| {
3182                            let rem_dur = aura.end_time.map_or(time, |e| e.0) - time;
3183                            rem_dur > max_dur.0 * 0.9
3184                        });
3185                        if is_new_aura {
3186                            let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
3187                            self.add_particles(
3188                                scene_data.particles_chance,
3189                                aura.radius.powi(2) as usize * usize::from(heartbeats) / 300,
3190                                || {
3191                                    let rand_angle = rng.random_range(0.0..TAU);
3192                                    let offset =
3193                                        Vec2::new(rand_angle.cos(), rand_angle.sin()) * aura.radius;
3194                                    let z_start = body_maybe
3195                                        .map_or(0.0, |b| rng.random_range(0.5..0.75) * b.height());
3196                                    let z_end = body_maybe
3197                                        .map_or(0.0, |b| rng.random_range(0.0..3.0) * b.height());
3198                                    Particle::new_directed(
3199                                        Duration::from_secs(3),
3200                                        time,
3201                                        ParticleMode::Ice,
3202                                        pos + Vec3::unit_z() * z_start,
3203                                        pos + offset.with_z(z_end),
3204                                        scene_data,
3205                                    )
3206                                },
3207                            );
3208                        }
3209                    },
3210                    aura::AuraKind::Buff {
3211                        kind: buff::BuffKind::Heatstroke,
3212                        ..
3213                    } => {
3214                        let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
3215                        self.add_particles(
3216                            scene_data.particles_chance,
3217                            aura.radius.powi(2) as usize * usize::from(heartbeats) / 900,
3218                            || {
3219                                let rand_dist = aura.radius * (1.0 - rng.random::<f32>().powi(100));
3220                                let init_pos = Vec3::new(rand_dist, 0_f32, 0_f32);
3221                                let duration = Duration::from_secs_f64(
3222                                    aura.end_time
3223                                        .map_or(1.0, |end| end.0 - time)
3224                                        .clamp(0.0, 1.0),
3225                                );
3226                                Particle::new_directed(
3227                                    duration,
3228                                    time,
3229                                    ParticleMode::EnergyPhoenix,
3230                                    pos,
3231                                    pos + init_pos,
3232                                    scene_data,
3233                                )
3234                            },
3235                        );
3236
3237                        let num_particles = aura.radius.powi(2) * dt / 50.0;
3238                        let num_particles = num_particles.floor() as usize
3239                            + usize::from(rng.random_bool(f64::from(num_particles % 1.0)));
3240                        self.add_particles(scene_data.particles_chance, num_particles, || {
3241                            let rand_pos = {
3242                                let theta = rng.random::<f32>() * TAU;
3243                                let radius = aura.radius * rng.random::<f32>().sqrt();
3244                                let x = radius * theta.sin();
3245                                let y = radius * theta.cos();
3246                                Vec2::new(x, y) + pos.xy()
3247                            };
3248                            let duration = Duration::from_secs_f64(
3249                                aura.end_time
3250                                    .map_or(1.0, |end| end.0 - time)
3251                                    .clamp(0.0, 1.0),
3252                            );
3253                            Particle::new_directed(
3254                                duration,
3255                                time,
3256                                ParticleMode::FieryBurstAsh,
3257                                pos,
3258                                Vec3::new(
3259                                                    0.0,    // radius of rand spawn
3260                                                    20.0,   // integer part - radius of the curve part, fractional part - relative time of setting particle on fire
3261                                                    5.5)    // height of the flight
3262                                                    + rand_pos.with_z(pos.z),
3263                                scene_data,
3264                            )
3265                        });
3266                    },
3267                    _ => {},
3268                }
3269            }
3270        }
3271    }
3272
3273    fn maintain_buff_particles(&mut self, scene_data: &SceneData) {
3274        let state = scene_data.state;
3275        let ecs = state.ecs();
3276        let time = state.get_time();
3277        let mut rng = rand::rng();
3278
3279        for (interp, pos, buffs, body, ori, scale) in (
3280            ecs.read_storage::<Interpolated>().maybe(),
3281            &ecs.read_storage::<Pos>(),
3282            &ecs.read_storage::<comp::Buffs>(),
3283            &ecs.read_storage::<Body>(),
3284            &ecs.read_storage::<Ori>(),
3285            ecs.read_storage::<Scale>().maybe(),
3286        )
3287            .join()
3288        {
3289            let pos = interp.map_or(pos.0, |i| i.pos);
3290
3291            for (buff_kind, buff_keys) in buffs
3292                .kinds
3293                .iter()
3294                .filter_map(|(kind, keys)| keys.as_ref().map(|keys| (kind, keys)))
3295            {
3296                use buff::BuffKind;
3297                match buff_kind {
3298                    BuffKind::Cursed | BuffKind::Burning => {
3299                        let final_amount =
3300                            usize::from(self.scheduler.heartbeats(Duration::from_millis(15)));
3301                        self.add_particles(scene_data.particles_chance, final_amount, || {
3302                            let start_pos = pos
3303                                + Vec3::unit_z() * body.height() * 0.25
3304                                + Vec3::<f32>::zero()
3305                                    .map(|_| rng.random_range(-1.0..1.0))
3306                                    .normalized()
3307                                    * 0.25;
3308                            let end_pos = start_pos
3309                                + Vec3::unit_z() * body.height()
3310                                + Vec3::<f32>::zero()
3311                                    .map(|_| rng.random_range(-1.0..1.0))
3312                                    .normalized();
3313                            Particle::new_directed(
3314                                Duration::from_secs(1),
3315                                time,
3316                                if matches!(buff_kind, BuffKind::Cursed) {
3317                                    ParticleMode::CultistFlame
3318                                } else {
3319                                    ParticleMode::FlameThrower
3320                                },
3321                                start_pos,
3322                                end_pos,
3323                                scene_data,
3324                            )
3325                        });
3326                    },
3327                    BuffKind::PotionSickness => {
3328                        let mut multiplicity = 0;
3329                        // Only show particles for potion sickness at the beginning, after the
3330                        // drinking animation finishes
3331                        if buff_keys.0
3332                            .iter()
3333                            .filter_map(|key| buffs.buffs.get(*key))
3334                            .any(|buff| {
3335                                matches!(buff.elapsed(Time(time)), dur if (1.0..=1.5).contains(&dur.0))
3336                            })
3337                        {
3338                            multiplicity = 1;
3339                        }
3340                        let final_amount = multiplicity
3341                            * usize::from(self.scheduler.heartbeats(Duration::from_millis(25)));
3342                        self.add_particles(scene_data.particles_chance, final_amount, || {
3343                            let start_pos =
3344                                pos + Vec3::unit_z() * body.eye_height(scale.map_or(1.0, |s| s.0));
3345                            let (radius, theta) = (
3346                                rng.random_range(0.0f32..1.0).sqrt(),
3347                                rng.random_range(0.0..TAU),
3348                            );
3349                            let end_pos = pos
3350                                + *ori.look_dir()
3351                                + Vec3::<f32>::new(radius * theta.cos(), radius * theta.sin(), 0.0)
3352                                    * 0.25;
3353                            Particle::new_directed(
3354                                Duration::from_secs(1),
3355                                time,
3356                                ParticleMode::PotionSickness,
3357                                start_pos,
3358                                end_pos,
3359                                scene_data,
3360                            )
3361                        });
3362                    },
3363                    BuffKind::Frenzied => {
3364                        let final_amount =
3365                            usize::from(self.scheduler.heartbeats(Duration::from_millis(15)));
3366                        self.add_particles(scene_data.particles_chance, final_amount, || {
3367                            let start_pos = pos
3368                                + Vec3::new(
3369                                    body.max_radius(),
3370                                    body.max_radius(),
3371                                    body.height() / 2.0,
3372                                )
3373                                .map(|d| d * rng.random_range(-1.0..1.0));
3374                            let end_pos = start_pos
3375                                + Vec3::unit_z() * body.height()
3376                                + Vec3::<f32>::zero()
3377                                    .map(|_| rng.random_range(-1.0..1.0))
3378                                    .normalized();
3379                            Particle::new_directed(
3380                                Duration::from_secs(1),
3381                                time,
3382                                ParticleMode::Enraged,
3383                                start_pos,
3384                                end_pos,
3385                                scene_data,
3386                            )
3387                        });
3388                    },
3389                    BuffKind::Polymorphed => {
3390                        let mut multiplicity = 0;
3391                        // Only show particles for polymorph at the beginning, after the
3392                        // drinking animation finishes
3393                        if buff_keys.0
3394                            .iter()
3395                            .filter_map(|key| buffs.buffs.get(*key))
3396                            .any(|buff| {
3397                                matches!(buff.elapsed(Time(time)), dur if (0.1..=0.3).contains(&dur.0))
3398                            })
3399                        {
3400                            multiplicity = 1;
3401                        }
3402                        let final_amount = multiplicity
3403                            * self.scheduler.heartbeats(Duration::from_millis(3)) as usize;
3404                        self.add_particles(scene_data.particles_chance, final_amount, || {
3405                            let start_pos = pos
3406                                + Vec3::unit_z() * body.eye_height(scale.map_or(1.0, |s| s.0))
3407                                    / 2.0;
3408                            let end_pos = start_pos
3409                                + Vec3::<f32>::zero()
3410                                    .map(|_| rng.random_range(-1.0..1.0))
3411                                    .normalized()
3412                                    * 5.0;
3413
3414                            Particle::new_directed(
3415                                Duration::from_secs(2),
3416                                time,
3417                                ParticleMode::Explosion,
3418                                start_pos,
3419                                end_pos,
3420                                scene_data,
3421                            )
3422                        })
3423                    },
3424                    BuffKind::IgniteArrow => {
3425                        let final_amount =
3426                            usize::from(self.scheduler.heartbeats(Duration::from_millis(150)));
3427                        self.add_particles(scene_data.particles_chance, final_amount, || {
3428                            let start_pos = pos
3429                                + Vec3::unit_z() * body.height() * 0.45
3430                                + ori.look_dir().xy().rotated_z(0.6) * body.front_radius() * 2.5
3431                                + Vec3::<f32>::zero()
3432                                    .map(|_| rng.random_range(-1.0..1.0))
3433                                    .normalized()
3434                                    * 0.05;
3435                            let end_pos = start_pos
3436                                + Vec3::unit_z() * 0.7
3437                                + Vec3::<f32>::zero()
3438                                    .map(|_| rng.random_range(-1.0..1.0))
3439                                    .normalized()
3440                                    * 0.05;
3441                            Particle::new_directed(
3442                                Duration::from_secs(1),
3443                                time,
3444                                ParticleMode::FlameThrower,
3445                                start_pos,
3446                                end_pos,
3447                                scene_data,
3448                            )
3449                        });
3450                    },
3451                    BuffKind::FreezeArrow => {
3452                        let final_amount =
3453                            usize::from(self.scheduler.heartbeats(Duration::from_millis(400)));
3454                        self.add_particles(scene_data.particles_chance, final_amount, || {
3455                            let start_pos = pos
3456                                + Vec3::unit_z() * body.height() * 0.45
3457                                + ori.look_dir().xy().rotated_z(0.6) * body.front_radius() * 2.5
3458                                + Vec3::<f32>::zero()
3459                                    .map(|_| rng.random_range(-1.0..1.0))
3460                                    .normalized()
3461                                    * 0.05;
3462                            let end_pos = start_pos
3463                                + Vec3::unit_z() * 1.0
3464                                + Vec3::<f32>::zero()
3465                                    .map(|_| rng.random_range(-1.0..1.0))
3466                                    .normalized()
3467                                    * 0.05;
3468                            Particle::new_directed(
3469                                Duration::from_millis(500),
3470                                time,
3471                                ParticleMode::Ice,
3472                                start_pos,
3473                                end_pos,
3474                                scene_data,
3475                            )
3476                        });
3477                    },
3478                    BuffKind::DrenchArrow => {
3479                        let final_amount =
3480                            usize::from(self.scheduler.heartbeats(Duration::from_millis(500)));
3481                        self.add_particles(scene_data.particles_chance, final_amount, || {
3482                            let start_pos = pos
3483                                + Vec3::unit_z() * body.height() * 0.45
3484                                + ori.look_dir().xy().rotated_z(0.6) * body.front_radius() * 2.5
3485                                + Vec3::<f32>::zero()
3486                                    .map(|_| rng.random_range(-1.0..1.0))
3487                                    .normalized()
3488                                    * 0.05;
3489                            let end_pos = start_pos - Vec3::unit_z() * 0.7
3490                                + Vec3::<f32>::zero()
3491                                    .map(|_| rng.random_range(-1.0..1.0))
3492                                    .normalized()
3493                                    * 0.05;
3494                            Particle::new_directed(
3495                                Duration::from_secs(1),
3496                                time,
3497                                ParticleMode::CultistFlame,
3498                                start_pos,
3499                                end_pos,
3500                                scene_data,
3501                            )
3502                        });
3503                    },
3504                    BuffKind::JoltArrow => {
3505                        let final_amount =
3506                            usize::from(self.scheduler.heartbeats(Duration::from_millis(20)));
3507                        self.add_particles(scene_data.particles_chance, final_amount, || {
3508                            let start_pos = pos
3509                                + Vec3::unit_z() * body.height() * 0.45
3510                                + ori.look_dir().xy().rotated_z(0.6) * body.front_radius() * 2.5
3511                                + Vec3::<f32>::zero()
3512                                    .map(|_| rng.random_range(-1.0..1.0))
3513                                    .normalized()
3514                                    * 0.2;
3515                            let end_pos = start_pos
3516                                + Vec3::<f32>::zero()
3517                                    .map(|_| rng.random_range(-1.0..1.0))
3518                                    .normalized()
3519                                    * 0.5;
3520                            Particle::new_directed(
3521                                Duration::from_millis(150),
3522                                time,
3523                                ParticleMode::ElectricSparks,
3524                                start_pos,
3525                                end_pos,
3526                                scene_data,
3527                            )
3528                        });
3529                    },
3530                    _ => {},
3531                }
3532            }
3533        }
3534    }
3535
3536    fn maintain_block_particles(
3537        &mut self,
3538        scene_data: &SceneData,
3539        terrain: &Terrain<TerrainChunk>,
3540        figure_mgr: &FigureMgr,
3541    ) {
3542        prof_span!("ParticleMgr::maintain_block_particles");
3543        let dt = scene_data.state.ecs().fetch::<DeltaTime>().0;
3544        let time = scene_data.state.get_time();
3545        let player_pos = scene_data
3546            .state
3547            .read_component_copied::<Interpolated>(scene_data.viewpoint_entity)
3548            .map(|i| i.pos)
3549            .unwrap_or_default();
3550        let player_chunk = player_pos.xy().map2(TerrainChunk::RECT_SIZE, |e, sz| {
3551            (e.floor() as i32).div_euclid(sz as i32)
3552        });
3553
3554        struct BlockParticles<'a> {
3555            // The function to select the blocks of interest that we should emit from
3556            blocks: fn(&'a BlocksOfInterest) -> BlockParticleSlice<'a>,
3557            // The range, in chunks, that the particles should be generated in from the player
3558            range: usize,
3559            // The emission rate, per block per second, of the generated particles
3560            rate: f32,
3561            // The number of seconds that each particle should live for
3562            lifetime: f32,
3563            // The visual mode of the generated particle
3564            mode: ParticleMode,
3565            // Condition that must be true
3566            cond: fn(&SceneData) -> bool,
3567            // The amount of sunlight that should be applied to the particles. Particles that spawn
3568            // in unlit conditions like caves may wish to use `0.0`.
3569            sunlight_level: f32,
3570        }
3571
3572        enum BlockParticleSlice<'a> {
3573            Positions(&'a [Vec3<i32>]),
3574            PositionsAndDirs(&'a [(Vec3<i32>, Vec3<f32>)]),
3575        }
3576
3577        impl BlockParticleSlice<'_> {
3578            fn len(&self) -> usize {
3579                match self {
3580                    Self::Positions(blocks) => blocks.len(),
3581                    Self::PositionsAndDirs(blocks) => blocks.len(),
3582                }
3583            }
3584        }
3585
3586        let particles: &[BlockParticles] = &[
3587            BlockParticles {
3588                blocks: |boi| BlockParticleSlice::Positions(&boi.leaves),
3589                range: 4,
3590                rate: 0.0125,
3591                lifetime: 30.0,
3592                mode: ParticleMode::Leaf,
3593                cond: |_| true,
3594                sunlight_level: 1.0,
3595            },
3596            BlockParticles {
3597                blocks: |boi| BlockParticleSlice::Positions(&boi.water),
3598                range: 4,
3599                rate: 0.003,
3600                lifetime: 30.0,
3601                // Ambient bubbles move up toward their origin, not up away from their origin
3602                mode: ParticleMode::BubbleAmbient,
3603                cond: |_| true,
3604                sunlight_level: 1.0,
3605            },
3606            BlockParticles {
3607                blocks: |boi| BlockParticleSlice::Positions(&boi.cave_roof),
3608                range: 4,
3609                rate: 0.015,
3610                lifetime: 30.0,
3611                mode: ParticleMode::CaveDust,
3612                cond: |_| true,
3613                sunlight_level: 0.0,
3614            },
3615            BlockParticles {
3616                blocks: |boi| BlockParticleSlice::Positions(&boi.drip),
3617                range: 4,
3618                rate: 0.004,
3619                lifetime: 20.0,
3620                mode: ParticleMode::Drip,
3621                cond: |_| true,
3622                sunlight_level: 0.0,
3623            },
3624            BlockParticles {
3625                blocks: |boi| BlockParticleSlice::Positions(&boi.fires),
3626                range: 2,
3627                rate: 50.0,
3628                lifetime: 0.5,
3629                mode: ParticleMode::CampfireFire,
3630                cond: |_| true,
3631                sunlight_level: 1.0,
3632            },
3633            BlockParticles {
3634                blocks: |boi| BlockParticleSlice::Positions(&boi.fire_bowls),
3635                range: 2,
3636                rate: 20.0,
3637                lifetime: 0.25,
3638                mode: ParticleMode::FireBowl,
3639                cond: |_| true,
3640                sunlight_level: 1.0,
3641            },
3642            BlockParticles {
3643                blocks: |boi| BlockParticleSlice::Positions(&boi.fireflies),
3644                range: 6,
3645                rate: 0.004,
3646                lifetime: 40.0,
3647                mode: ParticleMode::Firefly,
3648                cond: |sd| sd.state.get_day_period().is_dark(),
3649                sunlight_level: 1.0,
3650            },
3651            BlockParticles {
3652                blocks: |boi| BlockParticleSlice::Positions(&boi.flowers),
3653                range: 5,
3654                rate: 0.002,
3655                lifetime: 40.0,
3656                mode: ParticleMode::Firefly,
3657                cond: |sd| sd.state.get_day_period().is_dark(),
3658                sunlight_level: 1.0,
3659            },
3660            BlockParticles {
3661                blocks: |boi| BlockParticleSlice::Positions(&boi.beehives),
3662                range: 3,
3663                rate: 0.5,
3664                lifetime: 30.0,
3665                mode: ParticleMode::Bee,
3666                cond: |sd| sd.state.get_day_period().is_light(),
3667                sunlight_level: 1.0,
3668            },
3669            BlockParticles {
3670                blocks: |boi| BlockParticleSlice::Positions(&boi.snow),
3671                range: 4,
3672                rate: 0.025,
3673                lifetime: 15.0,
3674                mode: ParticleMode::Snow,
3675                cond: |_| true,
3676                sunlight_level: 1.0,
3677            },
3678            BlockParticles {
3679                blocks: |boi| BlockParticleSlice::PositionsAndDirs(&boi.one_way_walls),
3680                range: 2,
3681                rate: 12.0,
3682                lifetime: 1.5,
3683                mode: ParticleMode::PortalFizz,
3684                cond: |_| true,
3685                sunlight_level: 1.0,
3686            },
3687            BlockParticles {
3688                blocks: |boi| BlockParticleSlice::Positions(&boi.spores),
3689                range: 4,
3690                rate: 0.055,
3691                lifetime: 20.0,
3692                mode: ParticleMode::Spore,
3693                cond: |_| true,
3694                sunlight_level: 1.0,
3695            },
3696            BlockParticles {
3697                blocks: |boi| BlockParticleSlice::PositionsAndDirs(&boi.waterfall),
3698                range: 2,
3699                rate: 4.0,
3700                lifetime: 5.0,
3701                mode: ParticleMode::WaterFoam,
3702                cond: |_| true,
3703                sunlight_level: 1.0,
3704            },
3705            BlockParticles {
3706                blocks: |boi| BlockParticleSlice::Positions(&boi.train_smokes),
3707                range: 2,
3708                rate: 50.0,
3709                lifetime: 8.0,
3710                mode: ParticleMode::TrainSmoke,
3711                cond: |_| true,
3712                sunlight_level: 1.0,
3713            },
3714        ];
3715
3716        let ecs = scene_data.state.ecs();
3717        let mut rng = rand::rng();
3718        // Hard cap for performance reasons; Assuming that 25% of a chunk is covered in
3719        // lava or 32*32*0.25 = 256 TODO: Make this a setting?
3720        let cap = 512;
3721        for particles in particles.iter() {
3722            if !(particles.cond)(scene_data) {
3723                continue;
3724            }
3725
3726            for offset in Spiral2d::new().take((particles.range * 2 + 1).pow(2)) {
3727                let chunk_pos = player_chunk + offset;
3728
3729                terrain.get(chunk_pos).map(|chunk_data| {
3730                    let blocks = (particles.blocks)(&chunk_data.blocks_of_interest);
3731
3732                    let avg_particles = dt * (blocks.len() as f32 * particles.rate).min(cap as f32);
3733                    let particle_count = avg_particles.trunc() as usize
3734                        + (rng.random::<f32>() < avg_particles.fract()) as usize;
3735
3736                    self.add_particles(scene_data.particles_chance, particle_count, || {
3737                        match blocks {
3738                            BlockParticleSlice::Positions(blocks) => {
3739                                // Can't fail, resize only occurs if blocks > 0
3740                                let block_pos = Vec3::from(
3741                                    chunk_pos * TerrainChunk::RECT_SIZE.map(|e| e as i32),
3742                                ) + blocks.choose(&mut rng).copied().unwrap();
3743                                Particle::new(
3744                                    Duration::from_secs_f32(particles.lifetime),
3745                                    time,
3746                                    particles.mode,
3747                                    block_pos.map(|e: i32| e as f32 + rng.random::<f32>()),
3748                                    scene_data,
3749                                )
3750                                .with_light(particles.sunlight_level, 0.0)
3751                            },
3752                            BlockParticleSlice::PositionsAndDirs(blocks) => {
3753                                // Can't fail, resize only occurs if blocks > 0
3754                                let (block_offset, particle_dir) =
3755                                    blocks.choose(&mut rng).copied().unwrap();
3756                                let block_pos = Vec3::from(
3757                                    chunk_pos * TerrainChunk::RECT_SIZE.map(|e| e as i32),
3758                                ) + block_offset;
3759                                let particle_pos =
3760                                    block_pos.map(|e: i32| e as f32 + rng.random::<f32>());
3761                                Particle::new_directed(
3762                                    Duration::from_secs_f32(particles.lifetime),
3763                                    time,
3764                                    particles.mode,
3765                                    particle_pos,
3766                                    particle_pos + particle_dir,
3767                                    scene_data,
3768                                )
3769                                .with_light(particles.sunlight_level, 0.0)
3770                            },
3771                        }
3772                    })
3773                });
3774            }
3775
3776            for (entity, body, interpolated, collider) in (
3777                &ecs.entities(),
3778                &ecs.read_storage::<comp::Body>(),
3779                &ecs.read_storage::<crate::ecs::comp::Interpolated>(),
3780                ecs.read_storage::<comp::Collider>().maybe(),
3781            )
3782                .join()
3783            {
3784                if let Some((blocks_of_interest, offset)) =
3785                    figure_mgr.get_blocks_of_interest(entity, body, collider)
3786                {
3787                    let mat = Mat4::from(interpolated.ori.to_quat())
3788                        .translated_3d(interpolated.pos)
3789                        * Mat4::translation_3d(offset);
3790
3791                    let blocks = (particles.blocks)(blocks_of_interest);
3792
3793                    let avg_particles = dt * blocks.len() as f32 * particles.rate;
3794                    let particle_count = avg_particles.trunc() as usize
3795                        + (rng.random::<f32>() < avg_particles.fract()) as usize;
3796
3797                    self.add_particles(scene_data.particles_chance, particle_count, || {
3798                        match blocks {
3799                            BlockParticleSlice::Positions(blocks) => {
3800                                let rel_pos = blocks
3801                                        .choose(&mut rng)
3802                                        .copied()
3803                                        // Can't fail, resize only occurs if blocks > 0
3804                                        .unwrap()
3805                                        .map(|e: i32| e as f32 + rng.random::<f32>());
3806                                let wpos = mat.mul_point(rel_pos);
3807
3808                                Particle::new(
3809                                    Duration::from_secs_f32(particles.lifetime),
3810                                    time,
3811                                    particles.mode,
3812                                    wpos,
3813                                    scene_data,
3814                                )
3815                            },
3816                            BlockParticleSlice::PositionsAndDirs(blocks) => {
3817                                // Can't fail, resize only occurs if blocks > 0
3818                                let (block_offset, particle_dir) =
3819                                    blocks.choose(&mut rng).copied().unwrap();
3820                                let particle_pos =
3821                                    block_offset.map(|e: i32| e as f32 + rng.random::<f32>());
3822                                let wpos = mat.mul_point(particle_pos);
3823                                Particle::new_directed(
3824                                    Duration::from_secs_f32(particles.lifetime),
3825                                    time,
3826                                    particles.mode,
3827                                    wpos,
3828                                    wpos + mat.mul_direction(particle_dir),
3829                                    scene_data,
3830                                )
3831                            },
3832                        }
3833                    })
3834                }
3835            }
3836        }
3837        // smoke is more complex as it comes with varying rate and color
3838        {
3839            struct SmokeProperties {
3840                position: Vec3<i32>,
3841                strength: f32,
3842                dry_chance: f32,
3843            }
3844
3845            let range = 8_usize;
3846            let rate = 3.0 / 128.0;
3847            let lifetime = 40.0;
3848            let time_of_day = scene_data
3849                .state
3850                .get_time_of_day()
3851                .rem_euclid(24.0 * 60.0 * 60.0) as f32;
3852
3853            let smokers = Spiral2d::new()
3854                .take((range * 2 + 1).pow(2))
3855                .flat_map(|offset| {
3856                    let chunk_pos = player_chunk + offset;
3857                    let block_pos =
3858                        Vec3::<i32>::from(chunk_pos * TerrainChunk::RECT_SIZE.map(|e| e as i32));
3859                    terrain.get(chunk_pos).into_iter().flat_map(move |chunk| {
3860                        chunk.blocks_of_interest.smokers.iter().map(move |smoker| {
3861                            (
3862                                block_pos.as_::<f32>() + smoker.position.as_(),
3863                                smoker.kind,
3864                                chunk.blocks_of_interest.temperature,
3865                                chunk.blocks_of_interest.humidity,
3866                            )
3867                        })
3868                    })
3869                })
3870                .chain(
3871                    (
3872                        &ecs.entities(),
3873                        &ecs.read_storage::<comp::Body>(),
3874                        &ecs.read_storage::<crate::ecs::comp::Interpolated>(),
3875                        ecs.read_storage::<comp::Collider>().maybe(),
3876                    )
3877                        .join()
3878                        .flat_map(|(entity, body, interpolated, collider)| {
3879                            figure_mgr
3880                                .get_blocks_of_interest(entity, body, collider)
3881                                .into_iter()
3882                                .flat_map(|(boi, offset)| {
3883                                    let mat = Mat4::from(interpolated.ori.to_quat())
3884                                        .translated_3d(interpolated.pos)
3885                                        * Mat4::translation_3d(offset);
3886                                    boi.smokers.iter().map(move |smoker| {
3887                                        (
3888                                            mat.mul_point(smoker.position.as_::<f32>() + 0.5),
3889                                            smoker.kind,
3890                                            0.0, // TODO: Actual temperature & humidity
3891                                            0.5,
3892                                        )
3893                                    })
3894                                })
3895                        })
3896                        .collect::<Vec<_>>(),
3897                );
3898
3899            let mut smoke_properties: Vec<SmokeProperties> = Vec::new();
3900            let mut sum = 0.0_f32;
3901            for (pos, kind, temperature, humidity) in smokers {
3902                let (strength, dry_chance) = {
3903                    match kind {
3904                        FireplaceType::House => {
3905                            let prop = crate::scene::smoke_cycle::smoke_at_time(
3906                                pos.round().as_(),
3907                                temperature,
3908                                time_of_day,
3909                            );
3910                            (
3911                                prop.0,
3912                                if prop.1 {
3913                                    // fire started, dark smoke
3914                                    0.8 - humidity
3915                                } else {
3916                                    // fire continues, light smoke
3917                                    1.2 - humidity
3918                                },
3919                            )
3920                        },
3921                        FireplaceType::Workshop => (128.0, 1.0),
3922                    }
3923                };
3924                sum += strength;
3925                smoke_properties.push(SmokeProperties {
3926                    position: pos.round().as_(),
3927                    strength,
3928                    dry_chance,
3929                });
3930            }
3931            let avg_particles = dt * sum * rate;
3932
3933            let particle_count = avg_particles.trunc() as usize
3934                + (rng.random::<f32>() < avg_particles.fract()) as usize;
3935            let chosen = smoke_properties
3936                .sample_weighted(&mut rng, particle_count, |smoker| smoker.strength);
3937            if let Ok(chosen) = chosen {
3938                self.particles.extend(chosen.map(|smoker| {
3939                    Particle::new(
3940                        Duration::from_secs_f32(lifetime),
3941                        time,
3942                        if rng.random::<f32>() > smoker.dry_chance {
3943                            ParticleMode::BlackSmoke
3944                        } else {
3945                            ParticleMode::CampfireSmoke
3946                        },
3947                        smoker.position.map(|e: i32| e as f32 + rng.random::<f32>()),
3948                        scene_data,
3949                    )
3950                }));
3951            }
3952        }
3953    }
3954
3955    fn maintain_shockwave_particles(&mut self, scene_data: &SceneData) {
3956        let state = scene_data.state;
3957        let ecs = state.ecs();
3958        let time = state.get_time();
3959        let dt = scene_data.state.ecs().fetch::<DeltaTime>().0;
3960        let terrain = scene_data.state.ecs().fetch::<TerrainGrid>();
3961
3962        for (_entity, interp, pos, ori, shockwave) in (
3963            &ecs.entities(),
3964            ecs.read_storage::<Interpolated>().maybe(),
3965            &ecs.read_storage::<Pos>(),
3966            &ecs.read_storage::<Ori>(),
3967            &ecs.read_storage::<Shockwave>(),
3968        )
3969            .join()
3970        {
3971            let pos = interp.map_or(pos.0, |i| i.pos);
3972            let ori = interp.map_or(*ori, |i| i.ori);
3973
3974            let elapsed = time - shockwave.creation.unwrap_or(time);
3975            let speed = shockwave.properties.speed;
3976
3977            let percent = elapsed as f32 / shockwave.properties.duration.as_secs_f32();
3978
3979            let distance = speed * elapsed as f32;
3980
3981            let radians = shockwave.properties.angle.to_radians();
3982
3983            let ori_vec = ori.look_vec();
3984            let theta = ori_vec.y.atan2(ori_vec.x) - radians / 2.0;
3985            let dtheta = radians / distance;
3986
3987            // Number of particles derived from arc length (for new particles at least, old
3988            // can be converted later)
3989            let arc_length = distance * radians;
3990
3991            use shockwave::FrontendSpecifier;
3992            match shockwave.properties.specifier {
3993                FrontendSpecifier::Ground => {
3994                    let heartbeats = self.scheduler.heartbeats(Duration::from_millis(2));
3995                    for heartbeat in 0..heartbeats {
3996                        // 1 / 3 the size of terrain voxel
3997                        let scale = 1.0 / 3.0;
3998
3999                        let scaled_speed = speed * scale;
4000
4001                        let sub_tick_interpolation = scaled_speed * 1000.0 * heartbeat as f32;
4002
4003                        let distance = speed * (elapsed as f32 - sub_tick_interpolation);
4004
4005                        let particle_count_factor = radians / (3.0 * scale);
4006                        let new_particle_count = distance * particle_count_factor;
4007
4008                        for d in 0..(new_particle_count as i32) {
4009                            let arc_position = theta + dtheta * d as f32 / particle_count_factor;
4010
4011                            let position = pos
4012                                + distance * Vec3::new(arc_position.cos(), arc_position.sin(), 0.0);
4013
4014                            // Arbitrary number chosen that is large enough to be able to accurately
4015                            // place particles most of the time, but also not too big as to make ray
4016                            // be too large (for performance reasons)
4017                            let half_ray_length = 10.0;
4018                            let mut last_air = false;
4019                            // TODO: Optimize ray to only be cast at most once per block per tick if
4020                            // it becomes an issue.
4021                            // From imbris:
4022                            //      each ray is ~2 us
4023                            //      at 30 FPS, it peaked at 113 rays in a tick
4024                            //      total time was 240 us (although potentially half that is
4025                            //          overhead from the profiling of each ray)
4026                            let _ = terrain
4027                                .ray(
4028                                    position + Vec3::unit_z() * half_ray_length,
4029                                    position - Vec3::unit_z() * half_ray_length,
4030                                )
4031                                .for_each(|block: &Block, pos: Vec3<i32>| {
4032                                    if block.is_solid() && block.get_sprite().is_none() {
4033                                        if last_air {
4034                                            let position = position.xy().with_z(pos.z as f32 + 1.0);
4035
4036                                            let position_snapped =
4037                                                ((position / scale).floor() + 0.5) * scale;
4038
4039                                            self.push_particle(
4040                                                scene_data.particles_chance,
4041                                                Particle::new(
4042                                                    Duration::from_millis(250),
4043                                                    time,
4044                                                    ParticleMode::GroundShockwave,
4045                                                    position_snapped,
4046                                                    scene_data,
4047                                                ),
4048                                            );
4049                                            last_air = false;
4050                                        }
4051                                    } else {
4052                                        last_air = true;
4053                                    }
4054                                })
4055                                .cast();
4056                        }
4057                    }
4058                },
4059                FrontendSpecifier::Fire => {
4060                    let heartbeats = self.scheduler.heartbeats(Duration::from_millis(2));
4061                    for _ in 0..heartbeats {
4062                        for d in 0..3 * distance as i32 {
4063                            let arc_position = theta + dtheta * d as f32 / 3.0;
4064
4065                            let position = pos
4066                                + distance * Vec3::new(arc_position.cos(), arc_position.sin(), 0.0);
4067
4068                            self.push_particle(
4069                                scene_data.particles_chance,
4070                                Particle::new(
4071                                    Duration::from_secs_f32((distance + 10.0) / 50.0),
4072                                    time,
4073                                    ParticleMode::FireShockwave,
4074                                    position,
4075                                    scene_data,
4076                                ),
4077                            );
4078                        }
4079                    }
4080                },
4081                FrontendSpecifier::FireLow => {
4082                    let heartbeats = self.scheduler.heartbeats(Duration::from_millis(2));
4083                    for heartbeat in 0..heartbeats {
4084                        // 1 / 3 the size of terrain voxel
4085                        let scale = 1.0 / 3.0;
4086
4087                        let scaled_speed = speed * scale;
4088
4089                        let sub_tick_interpolation = scaled_speed * 1000.0 * heartbeat as f32;
4090
4091                        let distance = speed * (elapsed as f32 - sub_tick_interpolation);
4092
4093                        let particle_count_factor = radians / (3.0 * scale);
4094                        let new_particle_count = distance * particle_count_factor;
4095
4096                        for d in 0..(new_particle_count as i32) {
4097                            let arc_position = theta + dtheta * d as f32 / particle_count_factor;
4098
4099                            let position = pos
4100                                + distance * Vec3::new(arc_position.cos(), arc_position.sin(), 0.0);
4101
4102                            // Arbitrary number chosen that is large enough to be able to accurately
4103                            // place particles most of the time, but also not too big as to make ray
4104                            // be too large (for performance reasons)
4105                            let half_ray_length = 10.0;
4106                            let mut last_air = false;
4107                            // TODO: Optimize ray to only be cast at most once per block per tick if
4108                            // it becomes an issue.
4109                            // From imbris:
4110                            //      each ray is ~2 us
4111                            //      at 30 FPS, it peaked at 113 rays in a tick
4112                            //      total time was 240 us (although potentially half that is
4113                            //          overhead from the profiling of each ray)
4114                            let _ = terrain
4115                                .ray(
4116                                    position + Vec3::unit_z() * half_ray_length,
4117                                    position - Vec3::unit_z() * half_ray_length,
4118                                )
4119                                .for_each(|block: &Block, pos: Vec3<i32>| {
4120                                    if block.is_solid() && block.get_sprite().is_none() {
4121                                        if last_air {
4122                                            let position = position.xy().with_z(pos.z as f32 + 1.0);
4123
4124                                            let position_snapped =
4125                                                ((position / scale).floor() + 0.5) * scale;
4126
4127                                            self.push_particle(
4128                                                scene_data.particles_chance,
4129                                                Particle::new(
4130                                                    Duration::from_millis(250),
4131                                                    time,
4132                                                    ParticleMode::FireLowShockwave,
4133                                                    position_snapped,
4134                                                    scene_data,
4135                                                ),
4136                                            );
4137                                            last_air = false;
4138                                        }
4139                                    } else {
4140                                        last_air = true;
4141                                    }
4142                                })
4143                                .cast();
4144                        }
4145                    }
4146                },
4147                FrontendSpecifier::Water => {
4148                    // 1 particle per unit length of arc
4149                    let particles_per_length = arc_length as usize;
4150                    let dtheta = radians / particles_per_length as f32;
4151                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
4152                    // lower duration = more particles
4153                    let heartbeats = self
4154                        .scheduler
4155                        .heartbeats(Duration::from_secs_f32(1.0 / speed));
4156
4157                    for i in 0..particles_per_length {
4158                        let angle = dtheta * i as f32;
4159                        let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
4160                        for j in 0..heartbeats {
4161                            // Sub tick dt
4162                            let dt = (j as f32 / heartbeats as f32) * dt;
4163                            let distance = distance + speed * dt;
4164                            let pos1 = pos + distance * direction - Vec3::unit_z();
4165                            let pos2 = pos1 + (Vec3::unit_z() + direction) * 3.0;
4166                            let time = time + dt as f64;
4167
4168                            self.push_particle(
4169                                scene_data.particles_chance,
4170                                Particle::new_directed(
4171                                    Duration::from_secs_f32(0.5),
4172                                    time,
4173                                    ParticleMode::Water,
4174                                    pos1,
4175                                    pos2,
4176                                    scene_data,
4177                                ),
4178                            );
4179                        }
4180                    }
4181                },
4182                FrontendSpecifier::Lightning => {
4183                    // 1 particle per unit length of arc
4184                    let particles_per_length = arc_length as usize;
4185                    let dtheta = radians / particles_per_length as f32;
4186                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
4187                    // lower duration = more particles
4188                    let heartbeats = self
4189                        .scheduler
4190                        .heartbeats(Duration::from_secs_f32(1.0 / speed));
4191
4192                    for i in 0..particles_per_length {
4193                        let angle = dtheta * i as f32;
4194                        let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
4195                        for j in 0..heartbeats {
4196                            // Sub tick dt
4197                            let dt = (j as f32 / heartbeats as f32) * dt;
4198                            let distance = distance + speed * dt;
4199                            let pos1 = pos + distance * direction - Vec3::unit_z();
4200                            let pos2 = pos1 + (Vec3::unit_z() + direction) * 3.0;
4201                            let time = time + dt as f64;
4202
4203                            self.push_particle(
4204                                scene_data.particles_chance,
4205                                Particle::new_directed(
4206                                    Duration::from_secs_f32(0.5),
4207                                    time,
4208                                    ParticleMode::Lightning,
4209                                    pos1,
4210                                    pos2,
4211                                    scene_data,
4212                                ),
4213                            );
4214                        }
4215                    }
4216                },
4217                FrontendSpecifier::Steam => {
4218                    // 1 particle per unit length of arc
4219                    let particles_per_length = arc_length as usize;
4220                    let dtheta = radians / particles_per_length as f32;
4221                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
4222                    // lower duration = more particles
4223                    let heartbeats = self
4224                        .scheduler
4225                        .heartbeats(Duration::from_secs_f32(1.0 / speed));
4226
4227                    for i in 0..particles_per_length {
4228                        let angle = dtheta * i as f32;
4229                        let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
4230                        for j in 0..heartbeats {
4231                            // Sub tick dt
4232                            let dt = (j as f32 / heartbeats as f32) * dt;
4233                            let distance = distance + speed * dt;
4234                            let pos1 = pos + distance * direction - Vec3::unit_z();
4235                            let pos2 = pos1 + (Vec3::unit_z() + direction) * 3.0;
4236                            let time = time + dt as f64;
4237
4238                            self.push_particle(
4239                                scene_data.particles_chance,
4240                                Particle::new_directed(
4241                                    Duration::from_secs_f32(0.5),
4242                                    time,
4243                                    ParticleMode::Steam,
4244                                    pos1,
4245                                    pos2,
4246                                    scene_data,
4247                                ),
4248                            );
4249                        }
4250                    }
4251                },
4252                FrontendSpecifier::Poison => {
4253                    // 1 particle per unit length of arc
4254                    let particles_per_length = arc_length as usize;
4255                    let dtheta = radians / particles_per_length as f32;
4256                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
4257                    // lower duration = more particles
4258                    let heartbeats = self
4259                        .scheduler
4260                        .heartbeats(Duration::from_secs_f32(1.0 / speed));
4261
4262                    for i in 0..particles_per_length {
4263                        let angle = theta + dtheta * i as f32;
4264                        let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
4265                        for j in 0..heartbeats {
4266                            // Sub tick dt
4267                            let dt = (j as f32 / heartbeats as f32) * dt;
4268                            let distance = distance + speed * dt;
4269                            let pos1 = pos + distance * direction - Vec3::unit_z();
4270                            let pos2 = pos1 + (Vec3::unit_z() + direction) * 3.0;
4271                            let time = time + dt as f64;
4272
4273                            self.push_particle(
4274                                scene_data.particles_chance,
4275                                Particle::new_directed(
4276                                    Duration::from_secs_f32(0.5),
4277                                    time,
4278                                    ParticleMode::Poison,
4279                                    pos1,
4280                                    pos2,
4281                                    scene_data,
4282                                ),
4283                            );
4284                        }
4285                    }
4286                },
4287                FrontendSpecifier::AcidCloud => {
4288                    let particles_per_height = 5;
4289                    // 1 particle per unit length of arc
4290                    let particles_per_length = arc_length as usize;
4291                    let dtheta = radians / particles_per_length as f32;
4292                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
4293                    // lower duration = more particles
4294                    let heartbeats = self
4295                        .scheduler
4296                        .heartbeats(Duration::from_secs_f32(1.0 / speed));
4297                    for i in 0..particles_per_height {
4298                        let height = (i as f32 / (particles_per_height as f32 - 1.0)) * 4.0;
4299                        for j in 0..particles_per_length {
4300                            let angle = theta + dtheta * j as f32;
4301                            let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
4302                            for k in 0..heartbeats {
4303                                // Sub tick dt
4304                                let dt = (k as f32 / heartbeats as f32) * dt;
4305                                let distance = distance + speed * dt;
4306                                let pos1 = pos + distance * direction - Vec3::unit_z()
4307                                    + Vec3::unit_z() * height;
4308                                let pos2 = pos1 + direction;
4309                                let time = time + dt as f64;
4310
4311                                self.push_particle(
4312                                    scene_data.particles_chance,
4313                                    Particle::new_directed(
4314                                        Duration::from_secs_f32(0.5),
4315                                        time,
4316                                        ParticleMode::Poison,
4317                                        pos1,
4318                                        pos2,
4319                                        scene_data,
4320                                    ),
4321                                );
4322                            }
4323                        }
4324                    }
4325                },
4326                FrontendSpecifier::Ink => {
4327                    // 1 particle per unit length of arc
4328                    let particles_per_length = arc_length as usize;
4329                    let dtheta = radians / particles_per_length as f32;
4330                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
4331                    // lower duration = more particles
4332                    let heartbeats = self
4333                        .scheduler
4334                        .heartbeats(Duration::from_secs_f32(1.0 / speed));
4335
4336                    for i in 0..particles_per_length {
4337                        let angle = theta + dtheta * i as f32;
4338                        let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
4339                        for j in 0..heartbeats {
4340                            // Sub tick dt
4341                            let dt = (j as f32 / heartbeats as f32) * dt;
4342                            let distance = distance + speed * dt;
4343                            let pos1 = pos + distance * direction - Vec3::unit_z();
4344                            let pos2 = pos1 + (Vec3::unit_z() + direction) * 3.0;
4345                            let time = time + dt as f64;
4346
4347                            self.push_particle(
4348                                scene_data.particles_chance,
4349                                Particle::new_directed(
4350                                    Duration::from_secs_f32(0.5),
4351                                    time,
4352                                    ParticleMode::Ink,
4353                                    pos1,
4354                                    pos2,
4355                                    scene_data,
4356                                ),
4357                            );
4358                        }
4359                    }
4360                },
4361                FrontendSpecifier::IceSpikes | FrontendSpecifier::Ice => {
4362                    // 1 / 3 the size of terrain voxel
4363                    let scale = 1.0 / 3.0;
4364                    let scaled_distance = distance / scale;
4365                    let scaled_speed = speed / scale;
4366
4367                    // 1 particle per scaled unit length of arc
4368                    let particles_per_length = (0.25 * arc_length / scale) as usize;
4369                    let dtheta = radians / particles_per_length as f32;
4370                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
4371                    // lower duration = more particles
4372                    let heartbeats = self
4373                        .scheduler
4374                        .heartbeats(Duration::from_secs_f32(3.0 / scaled_speed));
4375
4376                    // higher wave when wave doesn't require ground
4377                    let wave = if matches!(shockwave.properties.dodgeable, Dodgeable::Jump) {
4378                        0.5
4379                    } else {
4380                        8.0
4381                    };
4382                    // Used to make taller the further out spikes are
4383                    let height_scale = wave + 1.5 * percent;
4384                    for i in 0..particles_per_length {
4385                        let angle = theta + dtheta * i as f32;
4386                        let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
4387                        for j in 0..heartbeats {
4388                            // Sub tick dt
4389                            let dt = (j as f32 / heartbeats as f32) * dt;
4390                            let scaled_distance = scaled_distance + scaled_speed * dt;
4391                            let mut pos1 = pos + (scaled_distance * direction).floor() * scale;
4392                            let time = time + dt as f64;
4393
4394                            // Arbitrary number chosen that is large enough to be able to accurately
4395                            // place particles most of the time, but also not too big as to make ray
4396                            // be too large (for performance reasons)
4397                            let half_ray_length = 10.0;
4398                            let mut last_air = false;
4399                            // TODO: Optimize ray to only be cast at most once per block per tick if
4400                            // it becomes an issue.
4401                            // From imbris:
4402                            //      each ray is ~2 us
4403                            //      at 30 FPS, it peaked at 113 rays in a tick
4404                            //      total time was 240 us (although potentially half that is
4405                            //          overhead from the profiling of each ray)
4406                            let _ = terrain
4407                                .ray(
4408                                    pos1 + Vec3::unit_z() * half_ray_length,
4409                                    pos1 - Vec3::unit_z() * half_ray_length,
4410                                )
4411                                .for_each(|block: &Block, pos: Vec3<i32>| {
4412                                    if block.is_solid() && block.get_sprite().is_none() {
4413                                        if last_air {
4414                                            pos1 = pos1.xy().with_z(pos.z as f32 + 1.0);
4415                                            last_air = false;
4416                                        }
4417                                    } else {
4418                                        last_air = true;
4419                                    }
4420                                })
4421                                .cast();
4422
4423                            let get_positions = |a| {
4424                                let pos1 = match a {
4425                                    2 => pos1 + Vec3::unit_x() * scale,
4426                                    3 => pos1 - Vec3::unit_x() * scale,
4427                                    4 => pos1 + Vec3::unit_y() * scale,
4428                                    5 => pos1 - Vec3::unit_y() * scale,
4429                                    _ => pos1,
4430                                };
4431                                let pos2 = if a == 1 {
4432                                    pos1 + Vec3::unit_z() * 5.0 * height_scale
4433                                } else {
4434                                    pos1 + Vec3::unit_z() * 1.0 * height_scale
4435                                };
4436                                (pos1, pos2)
4437                            };
4438
4439                            for a in 1..=5 {
4440                                let (pos1, pos2) = get_positions(a);
4441                                self.push_particle(
4442                                    scene_data.particles_chance,
4443                                    Particle::new_directed(
4444                                        Duration::from_secs_f32(0.5),
4445                                        time,
4446                                        ParticleMode::IceSpikes,
4447                                        pos1,
4448                                        pos2,
4449                                        scene_data,
4450                                    ),
4451                                );
4452                            }
4453                        }
4454                    }
4455                },
4456            }
4457        }
4458    }
4459
4460    fn maintain_marker_particles(&mut self, scene_data: &SceneData) {
4461        let state = scene_data.state;
4462        let ecs = state.ecs();
4463        let time = state.get_time();
4464        let mut rng = rand::rng();
4465
4466        for (interp, pos, vel, marker) in (
4467            ecs.read_storage::<Interpolated>().maybe(),
4468            &ecs.read_storage::<Pos>(),
4469            ecs.read_storage::<Vel>().maybe(),
4470            &ecs.read_storage::<comp::FrontendMarker>(),
4471        )
4472            .join()
4473        {
4474            let pos = interp.map_or(pos.0, |i| i.pos);
4475
4476            use comp::{FrontendMarker, visual::TorusMode};
4477            match marker {
4478                FrontendMarker::IgniteArrow => {
4479                    let final_amount =
4480                        usize::from(self.scheduler.heartbeats(Duration::from_millis(150)));
4481                    self.add_particles(scene_data.particles_chance, final_amount, || {
4482                        let start_pos = pos
4483                            + Vec3::<f32>::zero()
4484                                .map(|_| rng.random_range(-1.0..1.0))
4485                                .normalized()
4486                                * 0.05;
4487                        let end_pos = start_pos
4488                            + Vec3::unit_z() * 0.7
4489                            + Vec3::<f32>::zero()
4490                                .map(|_| rng.random_range(-1.0..1.0))
4491                                .normalized()
4492                                * 0.05;
4493                        Particle::new_directed(
4494                            Duration::from_secs(1),
4495                            time,
4496                            ParticleMode::FlameThrower,
4497                            start_pos,
4498                            end_pos,
4499                            scene_data,
4500                        )
4501                    });
4502                },
4503                FrontendMarker::FreezeArrow => {
4504                    let final_amount =
4505                        usize::from(self.scheduler.heartbeats(Duration::from_millis(400)));
4506                    self.add_particles(scene_data.particles_chance, final_amount, || {
4507                        let start_pos = pos
4508                            + Vec3::<f32>::zero()
4509                                .map(|_| rng.random_range(-1.0..1.0))
4510                                .normalized()
4511                                * 0.05;
4512                        let end_pos = start_pos
4513                            + Vec3::unit_z() * 1.0
4514                            + Vec3::<f32>::zero()
4515                                .map(|_| rng.random_range(-1.0..1.0))
4516                                .normalized()
4517                                * 0.05;
4518                        Particle::new_directed(
4519                            Duration::from_millis(500),
4520                            time,
4521                            ParticleMode::Ice,
4522                            start_pos,
4523                            end_pos,
4524                            scene_data,
4525                        )
4526                    });
4527                },
4528                FrontendMarker::DrenchArrow => {
4529                    let final_amount =
4530                        usize::from(self.scheduler.heartbeats(Duration::from_millis(500)));
4531                    self.add_particles(scene_data.particles_chance, final_amount, || {
4532                        let start_pos = pos
4533                            + Vec3::<f32>::zero()
4534                                .map(|_| rng.random_range(-1.0..1.0))
4535                                .normalized()
4536                                * 0.05;
4537                        let end_pos = start_pos - Vec3::unit_z() * 0.7
4538                            + Vec3::<f32>::zero()
4539                                .map(|_| rng.random_range(-1.0..1.0))
4540                                .normalized()
4541                                * 0.05;
4542                        Particle::new_directed(
4543                            Duration::from_secs(1),
4544                            time,
4545                            ParticleMode::CultistFlame,
4546                            start_pos,
4547                            end_pos,
4548                            scene_data,
4549                        )
4550                    });
4551                },
4552                FrontendMarker::JoltArrow => {
4553                    let final_amount =
4554                        usize::from(self.scheduler.heartbeats(Duration::from_millis(20)));
4555                    self.add_particles(scene_data.particles_chance, final_amount, || {
4556                        let start_pos = pos
4557                            + Vec3::<f32>::zero()
4558                                .map(|_| rng.random_range(-1.0..1.0))
4559                                .normalized()
4560                                * 0.2;
4561                        let end_pos = start_pos
4562                            + Vec3::<f32>::zero()
4563                                .map(|_| rng.random_range(-1.0..1.0))
4564                                .normalized()
4565                                * 0.5;
4566                        Particle::new_directed(
4567                            Duration::from_millis(150),
4568                            time,
4569                            ParticleMode::ElectricSparks,
4570                            start_pos,
4571                            end_pos,
4572                            scene_data,
4573                        )
4574                    });
4575                },
4576                FrontendMarker::Torus(major_r, torus_mode) => {
4577                    let time = scene_data.state.get_time();
4578                    let mut rng = rand::rng();
4579                    let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
4580
4581                    //Torus' orientation
4582                    let fwd = vel
4583                        .map(|v| v.0)
4584                        .unwrap_or(Vec3::unit_y())
4585                        .try_normalized()
4586                        .unwrap_or(Vec3::unit_y());
4587                    let right = fwd
4588                        .cross(Vec3::unit_z())
4589                        .try_normalized()
4590                        .unwrap_or(Vec3::unit_x());
4591                    let up = right.cross(fwd);
4592
4593                    let major_r = *major_r;
4594                    let minor_r: f32 = major_r / 1.5;
4595                    let flame_reach: f32 = fwd.magnitude();
4596
4597                    self.add_particles(
4598                        scene_data.particles_chance,
4599                        usize::from(heartbeats) * 8,
4600                        || {
4601                            let u = rng.random_range(0.0..TAU); // Angle around ring
4602                            let v = rng.random_range(0.0..TAU); // Angle within tube
4603
4604                            let radial = u.cos() * right + u.sin() * up;
4605                            let ring_center = pos + major_r * radial;
4606                            let tube_pos =
4607                                ring_center + minor_r * v.cos() * radial + minor_r * v.sin() * fwd;
4608
4609                            let mode = match torus_mode {
4610                                TorusMode::RedBlueFire => {
4611                                    if rng.random_bool(0.4) {
4612                                        ParticleMode::FlamethrowerBlue
4613                                    } else {
4614                                        ParticleMode::FlameThrower
4615                                    }
4616                                },
4617                            };
4618
4619                            // Zero wind velocity so the torus holds its shape regardless of wind.
4620                            let lifespan: Duration = Duration::from_millis(220);
4621                            Particle {
4622                                alive_until: time + lifespan.as_secs_f64(),
4623                                instance: ParticleInstance::new_directed(
4624                                    time,
4625                                    lifespan.as_secs_f32(),
4626                                    mode,
4627                                    tube_pos,
4628                                    tube_pos + radial * flame_reach,
4629                                    Vec2::zero(),
4630                                ),
4631                            }
4632                        },
4633                    );
4634                },
4635            }
4636        }
4637    }
4638
4639    fn maintain_arcing_particles(&mut self, scene_data: &SceneData) {
4640        let state = scene_data.state;
4641        let ecs = state.ecs();
4642        let time = state.get_time();
4643        let mut rng = rand::rng();
4644        let id_maps = ecs.read_resource::<IdMaps>();
4645
4646        for (interp, pos, arcing) in (
4647            ecs.read_storage::<Interpolated>().maybe(),
4648            &ecs.read_storage::<Pos>(),
4649            &ecs.read_storage::<comp::Arcing>(),
4650        )
4651            .join()
4652        {
4653            let pos = interp.map_or(pos.0, |i| i.pos);
4654            let body = arcing
4655                .hit_entities
4656                .last()
4657                .and_then(|uid| id_maps.uid_entity(*uid))
4658                .and_then(|e| ecs.read_storage::<Body>().get(e).copied());
4659            let height = body.map_or(2.0, |b| b.height());
4660            let radius = body.map_or(1.0, |b| b.max_radius());
4661            let pos = pos + Vec3::unit_z() * height / 2.0;
4662            let final_amount = usize::from(self.scheduler.heartbeats(Duration::from_millis(5)));
4663            self.add_particles(scene_data.particles_chance, final_amount, || {
4664                let start_pos = pos
4665                    + Vec3::<f32>::zero()
4666                        .map(|_| rng.random_range(-1.0..1.0))
4667                        .normalized()
4668                        * radius;
4669                let end_pos = start_pos
4670                    + Vec3::<f32>::zero()
4671                        .map(|_| rng.random_range(-1.0..1.0))
4672                        .normalized()
4673                        * (radius + 0.5);
4674                Particle::new_directed(
4675                    Duration::from_millis(200),
4676                    time,
4677                    ParticleMode::ElectricSparks,
4678                    start_pos,
4679                    end_pos,
4680                    scene_data,
4681                )
4682            });
4683
4684            let num = arcing.hit_entities.len();
4685            if num > 1 && (time - arcing.last_arc_time.0 < arcing.properties.min_delay.0) {
4686                let last_pos = {
4687                    let last_hit = arcing
4688                        .hit_entities
4689                        .get(num - 2)
4690                        .and_then(|uid| id_maps.uid_entity(*uid));
4691                    let pos = last_hit.and_then(|e| ecs.read_storage::<Pos>().get(e).map(|p| p.0));
4692                    let height = last_hit
4693                        .and_then(|e| ecs.read_storage::<Body>().get(e).map(|b| b.height()))
4694                        .unwrap_or(2.0);
4695                    pos.map(|p| p + Vec3::unit_z() * height / 2.0)
4696                };
4697
4698                if let Some(last_pos) = last_pos {
4699                    let vector = last_pos - pos;
4700                    let dist = vector.magnitude();
4701                    let ctrl = pos + vector / 2.0 + Vec3::unit_z() * dist;
4702                    let bezier = QuadraticBezier3 {
4703                        start: last_pos,
4704                        ctrl,
4705                        end: pos,
4706                    };
4707                    let segments = (dist * 1.0).ceil() as i32 + 2;
4708                    for segment in 0..(segments - 1) {
4709                        let t_0 = segment as f32 / segments as f32;
4710                        let t_1 = (segment + 2) as f32 / segments as f32;
4711                        let final_amount =
4712                            usize::from(self.scheduler.heartbeats(Duration::from_millis(30)));
4713                        self.add_particles(scene_data.particles_chance, final_amount, || {
4714                            let start_pos = bezier.evaluate(t_0)
4715                                + Vec3::<f32>::zero()
4716                                    .map(|_| rng.random_range(-1.0..1.0))
4717                                    .normalized()
4718                                    * 0.2;
4719                            let end_pos = bezier.evaluate(t_1)
4720                                + Vec3::<f32>::zero()
4721                                    .map(|_| rng.random_range(-1.0..1.0))
4722                                    .normalized()
4723                                    * 0.2;
4724                            Particle::new_directed(
4725                                Duration::from_millis(150),
4726                                time,
4727                                ParticleMode::ElectricSparks,
4728                                start_pos,
4729                                end_pos,
4730                                scene_data,
4731                            )
4732                        });
4733                    }
4734                }
4735            }
4736        }
4737    }
4738
4739    fn maintain_pool_particles(&mut self, scene_data: &SceneData) {
4740        prof_span!("ParticleMgr::maintain_pool_particles");
4741        let state = scene_data.state;
4742        let ecs = state.ecs();
4743        let time = state.get_time();
4744        let mut rng = rand::rng();
4745
4746        for (interp, pos, pool) in (
4747            ecs.read_storage::<Interpolated>().maybe(),
4748            &ecs.read_storage::<Pos>(),
4749            &ecs.read_storage::<comp::Pool>(),
4750        )
4751            .join()
4752        {
4753            let pos = interp.map_or(pos.0, |i| i.pos);
4754            let radius = pool.properties.radius;
4755
4756            // Fire particles spread across the pool radius.
4757            let final_amount = usize::from(self.scheduler.heartbeats(Duration::from_millis(20)));
4758            self.add_particles(scene_data.particles_chance, final_amount, || {
4759                Particle::new(
4760                    Duration::from_millis(700),
4761                    time,
4762                    ParticleMode::CampfireFire,
4763                    pos + Vec3::new(
4764                        rng.random_range(-radius..radius),
4765                        rng.random_range(-radius..radius),
4766                        0.1,
4767                    ),
4768                    scene_data,
4769                )
4770            });
4771
4772            // Smoke rises above the pool.
4773            let final_amount = usize::from(self.scheduler.heartbeats(Duration::from_millis(60)));
4774            self.add_particles(scene_data.particles_chance, final_amount, || {
4775                Particle::new(
4776                    Duration::from_secs(6),
4777                    time,
4778                    ParticleMode::CampfireSmoke,
4779                    pos + Vec3::new(
4780                        rng.random_range(-radius * 0.6..radius * 0.6),
4781                        rng.random_range(-radius * 0.6..radius * 0.6),
4782                        rng.random_range(0.5..1.5),
4783                    ),
4784                    scene_data,
4785                )
4786            });
4787        }
4788    }
4789
4790    fn upload_particles(&mut self, renderer: &mut Renderer) {
4791        prof_span!("ParticleMgr::upload_particles");
4792        let all_cpu_instances = self
4793            .particles
4794            .iter()
4795            .map(|p| p.instance)
4796            .collect::<Vec<ParticleInstance>>();
4797
4798        // TODO: optimise buffer writes
4799        let gpu_instances = renderer.create_instances(&all_cpu_instances);
4800
4801        self.instances = gpu_instances;
4802    }
4803
4804    pub fn render<'a>(&'a self, drawer: &mut ParticleDrawer<'_, 'a>, scene_data: &SceneData) {
4805        prof_span!("ParticleMgr::render");
4806        if scene_data.particles_enabled {
4807            let model = &self
4808                .model_cache
4809                .get(DEFAULT_MODEL_KEY)
4810                .expect("Expected particle model in cache");
4811
4812            drawer.draw(model, &self.instances);
4813        }
4814    }
4815
4816    pub fn particle_count(&self) -> usize { self.instances.count() }
4817
4818    pub fn particle_count_visible(&self) -> usize { self.instances.count() }
4819}
4820
4821fn default_instances(renderer: &mut Renderer) -> Instances<ParticleInstance> {
4822    let empty_vec = Vec::new();
4823
4824    renderer.create_instances(&empty_vec)
4825}
4826
4827const DEFAULT_MODEL_KEY: &str = "voxygen.voxel.particle";
4828
4829fn default_cache(renderer: &mut Renderer) -> HashMap<&'static str, Model<ParticleVertex>> {
4830    let mut model_cache = HashMap::new();
4831
4832    model_cache.entry(DEFAULT_MODEL_KEY).or_insert_with(|| {
4833        let vox = DotVox::load_expect(DEFAULT_MODEL_KEY);
4834
4835        // NOTE: If we add texturing we may eventually try to share it among all
4836        // particles in a single atlas.
4837        let max_texture_size = renderer.max_texture_size();
4838        let max_size = Vec2::from(u16::try_from(max_texture_size).unwrap_or(u16::MAX));
4839        let mut greedy = GreedyMesh::new(max_size, crate::mesh::greedy::general_config());
4840
4841        let segment = Segment::from_vox_model_index(&vox.read().0, 0, None);
4842        let segment_size = segment.size();
4843        let mut mesh = generate_mesh_base_vol_particle(segment, &mut greedy).0;
4844        // Center particle vertices around origin
4845        for vert in mesh.vertices_mut() {
4846            vert.pos[0] -= segment_size.x as f32 / 2.0;
4847            vert.pos[1] -= segment_size.y as f32 / 2.0;
4848            vert.pos[2] -= segment_size.z as f32 / 2.0;
4849        }
4850
4851        // NOTE: Ignoring coloring / lighting for now.
4852        drop(greedy);
4853
4854        renderer
4855            .create_model(&mesh)
4856            .expect("Failed to create particle model")
4857    });
4858
4859    model_cache
4860}
4861
4862/// Accumulates heartbeats to be consumed on the next tick.
4863struct HeartbeatScheduler {
4864    /// Duration = Heartbeat Frequency/Intervals
4865    /// f64 = Last update time
4866    /// u8 = number of heartbeats since last update
4867    /// - if it's more frequent then tick rate, it could be 1 or more.
4868    /// - if it's less frequent then tick rate, it could be 1 or 0.
4869    /// - if it's equal to the tick rate, it could be between 2 and 0, due to
4870    ///   delta time variance etc.
4871    timers: HashMap<Duration, (f64, u8)>,
4872
4873    last_known_time: f64,
4874}
4875
4876impl HeartbeatScheduler {
4877    pub fn new() -> Self {
4878        HeartbeatScheduler {
4879            timers: HashMap::new(),
4880            last_known_time: 0.0,
4881        }
4882    }
4883
4884    /// updates the last elapsed times and elapsed counts
4885    /// this should be called once, and only once per tick.
4886    pub fn maintain(&mut self, now: f64) {
4887        prof_span!("HeartbeatScheduler::maintain");
4888        self.last_known_time = now;
4889
4890        for (frequency, (last_update, heartbeats)) in self.timers.iter_mut() {
4891            // the number of frequency cycles that have occurred.
4892            let total_heartbeats = (now - *last_update) / frequency.as_secs_f64();
4893
4894            // exclude partial frequency cycles
4895            let full_heartbeats = total_heartbeats.floor();
4896
4897            *heartbeats = full_heartbeats as u8;
4898
4899            // the remaining partial frequency cycle, as a decimal.
4900            let partial_heartbeat = total_heartbeats - full_heartbeats;
4901
4902            // the remaining partial frequency cycle, as a unit of time(f64).
4903            let partial_heartbeat_as_time = frequency.mul_f64(partial_heartbeat).as_secs_f64();
4904
4905            // now minus the left over heart beat count precision as seconds,
4906            // Note: we want to preserve incomplete heartbeats, and roll them
4907            // over into the next update.
4908            *last_update = now - partial_heartbeat_as_time;
4909        }
4910    }
4911
4912    /// returns the number of times this duration has elapsed since the last
4913    /// tick:
4914    ///   - if it's more frequent then tick rate, it could be 1 or more.
4915    ///   - if it's less frequent then tick rate, it could be 1 or 0.
4916    ///   - if it's equal to the tick rate, it could be between 2 and 0, due to
4917    ///     delta time variance.
4918    pub fn heartbeats(&mut self, frequency: Duration) -> u8 {
4919        prof_span!("HeartbeatScheduler::heartbeats");
4920        let last_known_time = self.last_known_time;
4921
4922        self.timers
4923            .entry(frequency)
4924            .or_insert_with(|| (last_known_time, 0))
4925            .1
4926    }
4927
4928    pub fn clear(&mut self) { self.timers.clear() }
4929}
4930
4931#[derive(Clone, Copy)]
4932struct Particle {
4933    alive_until: f64, // created_at + lifespan
4934    instance: ParticleInstance,
4935}
4936
4937impl Particle {
4938    fn new(
4939        lifespan: Duration,
4940        time: f64,
4941        mode: ParticleMode,
4942        pos: Vec3<f32>,
4943        scene_data: &SceneData,
4944    ) -> Self {
4945        Particle {
4946            alive_until: time + lifespan.as_secs_f64(),
4947            instance: ParticleInstance::new(
4948                time,
4949                lifespan.as_secs_f32(),
4950                mode,
4951                pos,
4952                scene_data.wind_vel,
4953            ),
4954        }
4955    }
4956
4957    fn new_directed(
4958        lifespan: Duration,
4959        time: f64,
4960        mode: ParticleMode,
4961        pos1: Vec3<f32>,
4962        pos2: Vec3<f32>,
4963        scene_data: &SceneData,
4964    ) -> Self {
4965        Particle {
4966            alive_until: time + lifespan.as_secs_f64(),
4967            instance: ParticleInstance::new_directed(
4968                time,
4969                lifespan.as_secs_f32(),
4970                mode,
4971                pos1,
4972                pos2,
4973                scene_data.wind_vel,
4974            ),
4975        }
4976    }
4977
4978    pub fn with_light(self, sun_light: f32, glow_light: f32) -> Self {
4979        Self {
4980            instance: self.instance.with_light(sun_light, glow_light),
4981            ..self
4982        }
4983    }
4984
4985    fn new_colored(
4986        lifespan: Duration,
4987        time: f64,
4988        mode: ParticleMode,
4989        pos: Vec3<f32>,
4990        col: Rgb<f32>,
4991        scene_data: &SceneData,
4992    ) -> Self {
4993        Particle {
4994            alive_until: time + lifespan.as_secs_f64(),
4995            instance: ParticleInstance::new_colored(
4996                time,
4997                lifespan.as_secs_f32(),
4998                mode,
4999                pos,
5000                col,
5001                scene_data.wind_vel,
5002            ),
5003        }
5004    }
5005
5006    fn new_directed_with_collision(
5007        lifespan: Duration,
5008        time: f64,
5009        mode: ParticleMode,
5010        pos1: Vec3<f32>,
5011        pos2: Vec3<f32>,
5012        scene_data: &SceneData,
5013        distance: impl Fn(Vec3<f32>, Vec3<f32>) -> f32,
5014    ) -> Self {
5015        let dir = pos2 - pos1;
5016        let end_distance = pos1.distance(pos2);
5017        let (end_pos, lifespawn) = if end_distance > 0.1 {
5018            let ratio = distance(pos1, pos2) / end_distance;
5019            (pos1 + ratio * dir, lifespan.mul_f32(ratio))
5020        } else {
5021            (pos2, lifespan)
5022        };
5023
5024        Self::new_directed(lifespawn, time, mode, pos1, end_pos, scene_data)
5025    }
5026}