veloren_voxygen/scene/
particle.rs

1use super::{FigureMgr, SceneData, Terrain, terrain::BlocksOfInterest};
2use crate::{
3    ecs::comp::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::terrain::FireplaceType,
10};
11use common::{
12    assets::{AssetExt, DotVoxAsset},
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, 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 = thread_rng();
73
74        match outcome {
75            Outcome::Lightning { pos } => {
76                self.particles.resize_with(self.particles.len() + 800, || {
77                    Particle::new_directed(
78                        Duration::from_secs_f32(rng.gen_range(0.5..1.0)),
79                        time,
80                        ParticleMode::Lightning,
81                        *pos + Vec3::new(0.0, 0.0, rng.gen_range(0.0..600.0)),
82                        *pos,
83                    )
84                });
85            },
86            Outcome::SpriteDelete { pos, sprite } => match sprite {
87                SpriteKind::SeaUrchin => {
88                    let pos = pos.map(|e| e as f32 + 0.5);
89                    self.particles.resize_with(self.particles.len() + 10, || {
90                        Particle::new_directed(
91                            Duration::from_secs_f32(rng.gen_range(0.1..0.5)),
92                            time,
93                            ParticleMode::Steam,
94                            pos + Vec3::new(0.0, 0.0, rng.gen_range(0.0..1.5)),
95                            pos,
96                        )
97                    });
98                },
99                SpriteKind::EnsnaringVines => {},
100                _ => {},
101            },
102            Outcome::Explosion {
103                pos,
104                power,
105                radius,
106                is_attack,
107                reagent,
108            } => {
109                if *is_attack {
110                    match reagent {
111                        Some(Reagent::Green) => {
112                            self.particles.resize_with(
113                                self.particles.len() + (60.0 * power.abs()) as usize,
114                                || {
115                                    Particle::new_directed(
116                                        Duration::from_secs_f32(rng.gen_range(0.2..3.0)),
117                                        time,
118                                        ParticleMode::EnergyNature,
119                                        *pos,
120                                        *pos + Vec3::<f32>::zero()
121                                            .map(|_| rng.gen_range(-1.0..1.0))
122                                            .normalized()
123                                            * rng.gen_range(1.0..*radius),
124                                    )
125                                },
126                            );
127                        },
128                        Some(Reagent::Red) => {
129                            self.particles.resize_with(
130                                self.particles.len() + (75.0 * power.abs()) as usize,
131                                || {
132                                    Particle::new_directed(
133                                        Duration::from_millis(500),
134                                        time,
135                                        ParticleMode::Explosion,
136                                        *pos,
137                                        *pos + Vec3::<f32>::zero()
138                                            .map(|_| rng.gen_range(-1.0..1.0))
139                                            .normalized()
140                                            * *radius,
141                                    )
142                                },
143                            );
144                        },
145                        Some(Reagent::White) => {
146                            self.particles.resize_with(
147                                self.particles.len() + (75.0 * power.abs()) as usize,
148                                || {
149                                    Particle::new_directed(
150                                        Duration::from_millis(500),
151                                        time,
152                                        ParticleMode::Ice,
153                                        *pos,
154                                        *pos + Vec3::<f32>::zero()
155                                            .map(|_| rng.gen_range(-1.0..1.0))
156                                            .normalized()
157                                            * *radius,
158                                    )
159                                },
160                            );
161                        },
162                        Some(Reagent::Purple) => {
163                            self.particles.resize_with(
164                                self.particles.len() + (75.0 * power.abs()) as usize,
165                                || {
166                                    Particle::new_directed(
167                                        Duration::from_millis(500),
168                                        time,
169                                        ParticleMode::CultistFlame,
170                                        *pos,
171                                        *pos + Vec3::<f32>::zero()
172                                            .map(|_| rng.gen_range(-1.0..1.0))
173                                            .normalized()
174                                            * *radius,
175                                    )
176                                },
177                            );
178                        },
179                        Some(Reagent::FireRain) => {
180                            self.particles.resize_with(
181                                self.particles.len() + (5.0 * power.abs()) as usize,
182                                || {
183                                    Particle::new_directed(
184                                        Duration::from_millis(300),
185                                        time,
186                                        ParticleMode::Explosion,
187                                        *pos,
188                                        *pos + Vec3::<f32>::zero()
189                                            .map(|_| rng.gen_range(-1.0..1.0))
190                                            .normalized()
191                                            * *radius,
192                                    )
193                                },
194                            );
195                        },
196                        Some(Reagent::FireGigas) => {
197                            self.particles.resize_with(
198                                self.particles.len() + (4.0 * radius.powi(2)) as usize,
199                                || {
200                                    Particle::new_directed(
201                                        Duration::from_millis(500),
202                                        time,
203                                        ParticleMode::FireGigasExplosion,
204                                        *pos,
205                                        *pos + Vec3::<f32>::zero()
206                                            .map(|_| rng.gen_range(-1.0..1.0))
207                                            .normalized()
208                                            * *radius,
209                                    )
210                                },
211                            );
212                        },
213                        _ => {},
214                    }
215                } else {
216                    self.particles.resize_with(
217                        self.particles.len() + if reagent.is_some() { 300 } else { 150 },
218                        || {
219                            Particle::new(
220                                Duration::from_millis(if reagent.is_some() { 1000 } else { 250 }),
221                                time,
222                                match reagent {
223                                    Some(Reagent::Blue) => ParticleMode::FireworkBlue,
224                                    Some(Reagent::Green) => ParticleMode::FireworkGreen,
225                                    Some(Reagent::Purple) => ParticleMode::FireworkPurple,
226                                    Some(Reagent::Red) => ParticleMode::FireworkRed,
227                                    Some(Reagent::White) => ParticleMode::FireworkWhite,
228                                    Some(Reagent::Yellow) => ParticleMode::FireworkYellow,
229                                    Some(Reagent::FireRain) => ParticleMode::FireworkYellow,
230                                    Some(Reagent::FireGigas) => ParticleMode::FireGigasExplosion,
231                                    None => ParticleMode::Shrapnel,
232                                },
233                                *pos,
234                            )
235                        },
236                    );
237
238                    self.particles.resize_with(
239                        self.particles.len() + if reagent.is_some() { 100 } else { 200 },
240                        || {
241                            Particle::new(
242                                Duration::from_secs(4),
243                                time,
244                                ParticleMode::CampfireSmoke,
245                                *pos + Vec3::<f32>::zero()
246                                    .map(|_| rng.gen_range(-1.0..1.0))
247                                    .normalized()
248                                    * *radius,
249                            )
250                        },
251                    );
252                }
253            },
254            Outcome::BreakBlock { pos, .. } => {
255                // TODO: Use color field when particle colors are a thing
256                self.particles.resize_with(self.particles.len() + 30, || {
257                    Particle::new(
258                        Duration::from_millis(200),
259                        time,
260                        ParticleMode::Shrapnel,
261                        pos.map(|e| e as f32 + 0.5),
262                    )
263                });
264            },
265            Outcome::DamagedBlock {
266                pos, stage_changed, ..
267            } => {
268                self.particles.resize_with(
269                    self.particles.len() + if *stage_changed { 30 } else { 10 },
270                    || {
271                        Particle::new(
272                            Duration::from_millis(if *stage_changed { 200 } else { 100 }),
273                            time,
274                            ParticleMode::Shrapnel,
275                            pos.map(|e| e as f32 + 0.5),
276                        )
277                    },
278                );
279            },
280            Outcome::SpriteUnlocked { .. } => {},
281            Outcome::FailedSpriteUnlock { pos } => {
282                // TODO: Use color field when particle colors are a thing
283                self.particles.resize_with(self.particles.len() + 10, || {
284                    Particle::new(
285                        Duration::from_millis(50),
286                        time,
287                        ParticleMode::Shrapnel,
288                        pos.map(|e| e as f32 + 0.5),
289                    )
290                });
291            },
292            Outcome::SummonedCreature { pos, body } => match body {
293                Body::BipedSmall(b) if matches!(b.species, body::biped_small::Species::Husk) => {
294                    self.particles.resize_with(
295                        self.particles.len()
296                            + 2 * usize::from(self.scheduler.heartbeats(Duration::from_millis(1))),
297                        || {
298                            let start_pos = pos + Vec3::unit_z() * body.height() / 2.0;
299                            let end_pos = pos
300                                + Vec3::new(
301                                    2.0 * rng.gen::<f32>() - 1.0,
302                                    2.0 * rng.gen::<f32>() - 1.0,
303                                    0.0,
304                                )
305                                .normalized()
306                                    * (body.max_radius() + 4.0)
307                                + Vec3::unit_z() * (body.height() + 2.0) * rng.gen::<f32>();
308
309                            Particle::new_directed(
310                                Duration::from_secs_f32(0.5),
311                                time,
312                                ParticleMode::CultistFlame,
313                                start_pos,
314                                end_pos,
315                            )
316                        },
317                    );
318                },
319                Body::BipedSmall(b) if matches!(b.species, body::biped_small::Species::Boreal) => {
320                    self.particles.resize_with(
321                        self.particles.len()
322                            + 2 * usize::from(self.scheduler.heartbeats(Duration::from_millis(1))),
323                        || {
324                            let start_pos = pos + Vec3::unit_z() * body.height() / 2.0;
325                            let end_pos = pos
326                                + Vec3::new(
327                                    2.0 * rng.gen::<f32>() - 1.0,
328                                    2.0 * rng.gen::<f32>() - 1.0,
329                                    0.0,
330                                )
331                                .normalized()
332                                    * (body.max_radius() + 4.0)
333                                + Vec3::unit_z() * (body.height() + 20.0) * rng.gen::<f32>();
334
335                            Particle::new_directed(
336                                Duration::from_secs_f32(0.5),
337                                time,
338                                ParticleMode::GigaSnow,
339                                start_pos,
340                                end_pos,
341                            )
342                        },
343                    );
344                },
345                Body::BipedSmall(b) if matches!(b.species, body::biped_small::Species::Ashen) => {
346                    self.particles.resize_with(
347                        self.particles.len()
348                            + 2 * usize::from(self.scheduler.heartbeats(Duration::from_millis(1))),
349                        || {
350                            let start_pos = pos + Vec3::unit_z() * body.height() / 2.0;
351                            let end_pos = pos
352                                + Vec3::new(
353                                    2.0 * rng.gen::<f32>() - 1.0,
354                                    2.0 * rng.gen::<f32>() - 1.0,
355                                    0.0,
356                                )
357                                .normalized()
358                                    * (body.max_radius() + 4.0)
359                                + Vec3::unit_z() * (body.height() + 20.0) * rng.gen::<f32>();
360
361                            Particle::new_directed(
362                                Duration::from_secs_f32(0.5),
363                                time,
364                                ParticleMode::FlameThrower,
365                                start_pos,
366                                end_pos,
367                            )
368                        },
369                    );
370                },
371                _ => {},
372            },
373            Outcome::ProjectileHit { pos, target, .. } => {
374                if target.is_some() {
375                    let ecs = scene_data.state.ecs();
376                    if target
377                        .and_then(|target| ecs.read_resource::<IdMaps>().uid_entity(target))
378                        .and_then(|entity| {
379                            ecs.read_storage::<Body>()
380                                .get(entity)
381                                .map(|body| body.bleeds())
382                        })
383                        .unwrap_or(false)
384                    {
385                        self.particles.resize_with(self.particles.len() + 30, || {
386                            Particle::new(
387                                Duration::from_millis(250),
388                                time,
389                                ParticleMode::Blood,
390                                *pos,
391                            )
392                        })
393                    };
394                };
395            },
396            Outcome::Block { pos, parry, .. } => {
397                if *parry {
398                    self.particles.resize_with(self.particles.len() + 10, || {
399                        Particle::new(
400                            Duration::from_millis(200),
401                            time,
402                            ParticleMode::GunPowderSpark,
403                            *pos + Vec3::unit_z(),
404                        )
405                    });
406                }
407            },
408            Outcome::GroundSlam { pos, .. } => {
409                self.particles.resize_with(self.particles.len() + 100, || {
410                    Particle::new(
411                        Duration::from_millis(1000),
412                        time,
413                        ParticleMode::BigShrapnel,
414                        *pos,
415                    )
416                });
417            },
418            Outcome::FireLowShockwave { pos, .. } => {
419                self.particles.resize_with(self.particles.len() + 100, || {
420                    Particle::new(
421                        Duration::from_millis(1000),
422                        time,
423                        ParticleMode::FireLowShockwave,
424                        *pos,
425                    )
426                });
427            },
428            Outcome::SurpriseEgg { pos, .. } => {
429                self.particles.resize_with(self.particles.len() + 50, || {
430                    Particle::new(
431                        Duration::from_millis(1000),
432                        time,
433                        ParticleMode::SurpriseEgg,
434                        *pos,
435                    )
436                });
437            },
438            Outcome::FlashFreeze { pos, .. } => {
439                self.particles.resize_with(
440                    self.particles.len()
441                        + 2 * usize::from(self.scheduler.heartbeats(Duration::from_millis(1))),
442                    || {
443                        let start_pos = pos + Vec3::unit_z() - 1.0;
444                        let end_pos = pos
445                            + Vec3::new(
446                                4.0 * rng.gen::<f32>() - 1.0,
447                                4.0 * rng.gen::<f32>() - 1.0,
448                                0.0,
449                            )
450                            .normalized()
451                                * 1.5
452                            + Vec3::unit_z()
453                            + 5.0 * rng.gen::<f32>();
454
455                        Particle::new_directed(
456                            Duration::from_secs_f32(0.5),
457                            time,
458                            ParticleMode::GigaSnow,
459                            start_pos,
460                            end_pos,
461                        )
462                    },
463                );
464            },
465            Outcome::CyclopsCharge { pos } => {
466                self.particles.push(Particle::new_directed(
467                    Duration::from_secs_f32(rng.gen_range(0.1..0.2)),
468                    time,
469                    ParticleMode::CyclopsCharge,
470                    *pos + Vec3::new(0.0, 0.0, 5.3),
471                    *pos + Vec3::new(0.0, 0.0, 5.6 + 0.5 * rng.gen_range(0.0..0.2)),
472                ));
473            },
474            Outcome::FlamethrowerCharge { pos } | Outcome::FuseCharge { pos } => {
475                self.particles.push(Particle::new_directed(
476                    Duration::from_secs_f32(rng.gen_range(0.1..0.2)),
477                    time,
478                    ParticleMode::CampfireFire,
479                    *pos + Vec3::new(0.0, 0.0, 1.2),
480                    *pos + Vec3::new(0.0, 0.0, 1.5 + 0.5 * rng.gen_range(0.0..0.2)),
481                ));
482            },
483            Outcome::TerracottaStatueCharge { pos } => {
484                self.particles.push(Particle::new_directed(
485                    Duration::from_secs_f32(rng.gen_range(0.1..0.2)),
486                    time,
487                    ParticleMode::FireworkYellow,
488                    *pos + Vec3::new(0.0, 0.0, 4.0),
489                    *pos + Vec3::new(0.0, 0.0, 5.0 + 0.5 * rng.gen_range(0.3..0.8)),
490                ));
491            },
492            Outcome::Death { pos, .. } => {
493                self.particles.resize_with(self.particles.len() + 40, || {
494                    Particle::new(
495                        Duration::from_millis(400 + rng.gen_range(0..100)),
496                        time,
497                        ParticleMode::Death,
498                        *pos + Vec3::unit_z()
499                            + Vec3::<f32>::zero()
500                                .map(|_| rng.gen_range(-0.1..0.1))
501                                .normalized(),
502                    )
503                });
504            },
505            Outcome::GroundDig { pos, .. } => {
506                self.particles.resize_with(self.particles.len() + 12, || {
507                    Particle::new(
508                        Duration::from_millis(200),
509                        time,
510                        ParticleMode::BigShrapnel,
511                        *pos,
512                    )
513                });
514            },
515            Outcome::TeleportedByPortal { pos, .. } => {
516                self.particles.resize_with(self.particles.len() + 80, || {
517                    Particle::new_directed(
518                        Duration::from_millis(500),
519                        time,
520                        ParticleMode::CultistFlame,
521                        *pos,
522                        pos + Vec3::unit_z()
523                            + Vec3::zero()
524                                .map(|_: f32| rng.gen_range(-0.1..0.1))
525                                .normalized()
526                                * 2.0,
527                    )
528                });
529            },
530            Outcome::ClayGolemDash { pos, .. } => {
531                self.particles.resize_with(self.particles.len() + 100, || {
532                    Particle::new(
533                        Duration::from_millis(1000),
534                        time,
535                        ParticleMode::ClayShrapnel,
536                        *pos,
537                    )
538                });
539            },
540            Outcome::HeadLost { uid, head } => {
541                if let Some(entity) = scene_data
542                    .state
543                    .ecs()
544                    .read_resource::<IdMaps>()
545                    .uid_entity(*uid)
546                {
547                    if let Some(pos) = scene_data.state.read_component_copied::<Pos>(entity) {
548                        let heads = figure_mgr.get_heads(scene_data, entity);
549                        let head_pos = pos.0 + heads.get(*head).copied().unwrap_or_default();
550
551                        self.particles.resize_with(self.particles.len() + 40, || {
552                            Particle::new(
553                                Duration::from_millis(1000),
554                                time,
555                                ParticleMode::Death,
556                                head_pos
557                                    + Vec3::<f32>::zero()
558                                        .map(|_| rng.gen_range(-0.1..0.1))
559                                        .normalized(),
560                            )
561                        });
562                    }
563                };
564            },
565            Outcome::Splash {
566                vel,
567                pos,
568                mass,
569                kind,
570            } => {
571                let mode = match kind {
572                    comp::fluid_dynamics::LiquidKind::Water => ParticleMode::WaterFoam,
573                    comp::fluid_dynamics::LiquidKind::Lava => ParticleMode::CampfireFire,
574                };
575                let magnitude = (-vel.z).max(0.0);
576                let energy = mass * magnitude;
577                if energy > 0.0 {
578                    let count = ((0.6 * energy.sqrt()).ceil() as usize).min(500);
579                    let mut i = 0;
580                    let r = 0.5 / count as f32;
581                    self.particles
582                        .resize_with(self.particles.len() + count, || {
583                            let t = i as f32 / count as f32 + rng.gen_range(-r..=r);
584                            i += 1;
585                            let angle = t * TAU;
586                            let s = angle.sin();
587                            let c = angle.cos();
588                            let energy = energy
589                                * f32::abs(rng.gen_range(0.0..1.0) + rng.gen_range(0.0..1.0) - 0.5);
590
591                            let axis = -Vec3::unit_z();
592                            let plane = Vec3::new(c, s, 0.0);
593
594                            let pos = *pos + plane * rng.gen_range(0.0..0.5);
595
596                            let energy = energy.sqrt() * 0.5;
597
598                            let dir = plane * (1.0 + energy) - axis * energy;
599
600                            Particle::new_directed(
601                                Duration::from_millis(4000),
602                                time,
603                                mode,
604                                pos,
605                                pos + dir,
606                            )
607                        });
608                }
609            },
610            Outcome::Transformation { pos } => {
611                self.particles.resize_with(self.particles.len() + 100, || {
612                    Particle::new(
613                        Duration::from_millis(1400),
614                        time,
615                        ParticleMode::Transformation,
616                        *pos,
617                    )
618                });
619            },
620            Outcome::FirePillarIndicator { pos, radius } => {
621                self.particles.resize_with(
622                    self.particles.len() + radius.powi(2) as usize / 2,
623                    || {
624                        Particle::new_directed(
625                            Duration::from_millis(500),
626                            time,
627                            ParticleMode::FirePillarIndicator,
628                            *pos + 0.2 * Vec3::<f32>::unit_z(),
629                            // unit_x choosen arbitrarily, particle shader only uses the distance
630                            // between pos1 and pos2, not the actual position
631                            *pos + 0.2 * Vec3::<f32>::unit_z() + *radius * Vec3::unit_x(),
632                        )
633                    },
634                );
635            },
636            Outcome::ProjectileShot { .. }
637            | Outcome::Beam { .. }
638            | Outcome::ExpChange { .. }
639            | Outcome::SkillPointGain { .. }
640            | Outcome::ComboChange { .. }
641            | Outcome::HealthChange { .. }
642            | Outcome::PoiseChange { .. }
643            | Outcome::Utterance { .. }
644            | Outcome::IceSpikes { .. }
645            | Outcome::IceCrack { .. }
646            | Outcome::Glider { .. }
647            | Outcome::Whoosh { .. }
648            | Outcome::Swoosh { .. }
649            | Outcome::Slash { .. }
650            | Outcome::Bleep { .. }
651            | Outcome::Charge { .. }
652            | Outcome::Steam { .. }
653            | Outcome::FireShockwave { .. }
654            | Outcome::PortalActivated { .. }
655            | Outcome::FromTheAshes { .. }
656            | Outcome::LaserBeam { .. } => {},
657        }
658    }
659
660    pub fn maintain(
661        &mut self,
662        renderer: &mut Renderer,
663        scene_data: &SceneData,
664        terrain: &Terrain<TerrainChunk>,
665        figure_mgr: &FigureMgr,
666        lights: &mut Vec<Light>,
667    ) {
668        prof_span!("ParticleMgr::maintain");
669        if scene_data.particles_enabled {
670            // update timings
671            self.scheduler.maintain(scene_data.state.get_time());
672
673            // remove dead Particle
674            self.particles
675                .retain(|p| p.alive_until > scene_data.state.get_time());
676
677            // add new Particle
678            self.maintain_armor_particles(scene_data, figure_mgr);
679            self.maintain_body_particles(scene_data);
680            self.maintain_char_state_particles(scene_data, figure_mgr);
681            self.maintain_beam_particles(scene_data, lights);
682            self.maintain_block_particles(scene_data, terrain, figure_mgr);
683            self.maintain_shockwave_particles(scene_data);
684            self.maintain_aura_particles(scene_data);
685            self.maintain_buff_particles(scene_data);
686
687            self.upload_particles(renderer);
688        } else {
689            // remove all particle lifespans
690            if !self.particles.is_empty() {
691                self.particles.clear();
692                self.upload_particles(renderer);
693            }
694
695            // remove all timings
696            self.scheduler.clear();
697        }
698    }
699
700    fn maintain_armor_particles(&mut self, scene_data: &SceneData, figure_mgr: &FigureMgr) {
701        prof_span!("ParticleMgr::maintain_armor_particles");
702        let ecs = scene_data.state.ecs();
703
704        for (entity, interpolated, body, scale, inv, physics) in (
705            &ecs.entities(),
706            &ecs.read_storage::<Interpolated>(),
707            &ecs.read_storage::<Body>(),
708            ecs.read_storage::<Scale>().maybe(),
709            &ecs.read_storage::<Inventory>(),
710            &ecs.read_storage::<PhysicsState>(),
711        )
712            .join()
713        {
714            for item in inv.equipped_items() {
715                if let ItemDefinitionId::Simple(str) = item.item_definition_id() {
716                    if &*str == "common.items.armor.misc.head.pipe" {
717                        self.maintain_pipe_particles(
718                            scene_data,
719                            figure_mgr,
720                            entity,
721                            interpolated.pos,
722                            body,
723                            scale,
724                            physics,
725                        )
726                    }
727                }
728            }
729        }
730    }
731
732    fn maintain_pipe_particles(
733        &mut self,
734        scene_data: &SceneData,
735        figure_mgr: &FigureMgr,
736        entity: Entity,
737        pos: Vec3<f32>,
738        body: &Body,
739        scale: Option<&Scale>,
740        physics: &PhysicsState,
741    ) {
742        prof_span!("ParticleMgr::maintain_pipe_particles");
743        if physics
744            .in_liquid()
745            .is_none_or(|depth| body.eye_height(scale.map_or(1.0, |scale| scale.0)) > depth)
746        {
747            let Body::Humanoid(body) = body else {
748                return;
749            };
750            let Some(skeleton) = figure_mgr
751                .states
752                .character_states
753                .get(&entity)
754                .map(|state| &state.computed_skeleton)
755            else {
756                return;
757            };
758            let time = scene_data.state.get_time();
759
760            // TODO: compute offsets instead of hardcoding
761            use body::humanoid::{BodyType::*, Species::*};
762            let pipe_offset = match (body.species, body.body_type) {
763                (Orc, Male) => Vec3::new(5.5, 10.5, 0.0),
764                (Orc, Female) => Vec3::new(4.5, 10.0, -2.5),
765                (Human, Male) => Vec3::new(4.5, 12.0, -3.0),
766                (Human, Female) => Vec3::new(4.5, 11.5, -3.0),
767                (Elf, Male) => Vec3::new(4.5, 12.0, -3.0),
768                (Elf, Female) => Vec3::new(4.5, 9.5, -3.0),
769                (Dwarf, Male) => Vec3::new(4.5, 11.0, -4.0),
770                (Dwarf, Female) => Vec3::new(4.5, 11.0, -3.0),
771                (Draugr, Male) => Vec3::new(4.5, 9.5, -0.75),
772                (Draugr, Female) => Vec3::new(4.5, 9.5, -2.0),
773                (Danari, Male) => Vec3::new(4.5, 10.5, -1.25),
774                (Danari, Female) => Vec3::new(4.5, 10.5, -1.25),
775            };
776
777            let mut rng = rand::thread_rng();
778            let dt = scene_data.state.get_delta_time();
779            if rng.gen_bool((0.16 * dt as f64).min(1.0)) {
780                self.particles.resize_with(self.particles.len() + 10, || {
781                    Particle::new(
782                        Duration::from_millis(1500),
783                        time,
784                        ParticleMode::PipeSmoke,
785                        pos + skeleton.head.mul_point(pipe_offset),
786                    )
787                });
788            }
789        }
790    }
791
792    fn maintain_body_particles(&mut self, scene_data: &SceneData) {
793        prof_span!("ParticleMgr::maintain_body_particles");
794        let ecs = scene_data.state.ecs();
795        for (body, interpolated, vel) in (
796            &ecs.read_storage::<Body>(),
797            &ecs.read_storage::<Interpolated>(),
798            ecs.read_storage::<Vel>().maybe(),
799        )
800            .join()
801        {
802            match body {
803                Body::Object(object::Body::CampfireLit) => {
804                    self.maintain_campfirelit_particles(scene_data, interpolated.pos, vel)
805                },
806                Body::Object(object::Body::BarrelOrgan) => {
807                    self.maintain_barrel_organ_particles(scene_data, interpolated.pos, vel)
808                },
809                Body::Object(object::Body::BoltFire) => {
810                    self.maintain_boltfire_particles(scene_data, interpolated.pos, vel)
811                },
812                Body::Object(object::Body::BoltFireBig) => {
813                    self.maintain_boltfirebig_particles(scene_data, interpolated.pos, vel)
814                },
815                Body::Object(object::Body::FireRainDrop) => {
816                    self.maintain_fireraindrop_particles(scene_data, interpolated.pos, vel)
817                },
818                Body::Object(object::Body::BoltNature) => {
819                    self.maintain_boltnature_particles(scene_data, interpolated.pos, vel)
820                },
821                Body::Object(object::Body::Tornado) => {
822                    self.maintain_tornado_particles(scene_data, interpolated.pos)
823                },
824                Body::Object(object::Body::FieryTornado) => {
825                    self.maintain_fiery_tornado_particles(scene_data, interpolated.pos)
826                },
827                Body::Object(object::Body::Mine) => {
828                    self.maintain_mine_particles(scene_data, interpolated.pos)
829                },
830                Body::Object(
831                    object::Body::Bomb
832                    | object::Body::FireworkBlue
833                    | object::Body::FireworkGreen
834                    | object::Body::FireworkPurple
835                    | object::Body::FireworkRed
836                    | object::Body::FireworkWhite
837                    | object::Body::FireworkYellow
838                    | object::Body::IronPikeBomb,
839                ) => self.maintain_bomb_particles(scene_data, interpolated.pos, vel),
840                Body::Object(object::Body::PortalActive) => {
841                    self.maintain_active_portal_particles(scene_data, interpolated.pos)
842                },
843                Body::Object(object::Body::Portal) => {
844                    self.maintain_portal_particles(scene_data, interpolated.pos)
845                },
846                Body::BipedLarge(biped_large::Body {
847                    species: biped_large::Species::Gigasfire,
848                    ..
849                }) => self.maintain_fire_gigas_particles(scene_data, interpolated.pos),
850                _ => {},
851            }
852        }
853    }
854
855    fn maintain_fire_gigas_particles(&mut self, scene_data: &SceneData, pos: Vec3<f32>) {
856        let time = scene_data.state.get_time();
857        let mut rng = rand::thread_rng();
858
859        if rng.gen_bool(0.05) {
860            self.particles.resize_with(self.particles.len() + 1, || {
861                let rand_offset = Vec3::new(
862                    rng.gen_range(-5.0..5.0),
863                    rng.gen_range(-5.0..5.0),
864                    rng.gen_range(7.0..15.0),
865                );
866
867                Particle::new(
868                    Duration::from_secs_f32(30.0),
869                    time,
870                    ParticleMode::FireGigasAsh,
871                    pos + rand_offset,
872                )
873            });
874        }
875    }
876
877    fn maintain_hydra_tail_swipe_particles(
878        &mut self,
879        scene_data: &SceneData,
880        figure_mgr: &FigureMgr,
881        entity: Entity,
882        pos: Vec3<f32>,
883        body: &Body,
884        state: &CharacterState,
885        inventory: Option<&Inventory>,
886    ) {
887        let Some(ability_id) = state
888            .ability_info()
889            .and_then(|info| info.ability.map(|a| a.ability_id(Some(state), inventory)))
890        else {
891            return;
892        };
893
894        if ability_id != Some("common.abilities.custom.hydra.tail_swipe") {
895            return;
896        }
897
898        let Some(stage_section) = state.stage_section() else {
899            return;
900        };
901
902        let particle_count = match stage_section {
903            StageSection::Charge => 1,
904            StageSection::Action => 10,
905            _ => return,
906        };
907
908        let Some(skeleton) = figure_mgr
909            .states
910            .quadruped_low_states
911            .get(&entity)
912            .map(|state| &state.computed_skeleton)
913        else {
914            return;
915        };
916        let Some(attr) = anim::quadruped_low::SkeletonAttr::try_from(body).ok() else {
917            return;
918        };
919
920        let start = (skeleton.tail_front * Vec4::unit_w()).xyz();
921        let end = (skeleton.tail_rear * Vec4::new(0.0, -attr.tail_rear_length, 0.0, 1.0)).xyz();
922
923        let start = pos + start;
924        let end = pos + end;
925
926        let time = scene_data.state.get_time();
927        let mut rng = thread_rng();
928
929        self.particles.resize_with(
930            self.particles.len()
931                + particle_count * self.scheduler.heartbeats(Duration::from_millis(33)) as usize,
932            || {
933                let t = rng.gen_range(0.0..1.0);
934                let p = start * t + end * (1.0 - t) - Vec3::new(0.0, 0.0, 0.5);
935
936                Particle::new(
937                    Duration::from_millis(500),
938                    time,
939                    ParticleMode::GroundShockwave,
940                    p,
941                )
942            },
943        );
944    }
945
946    fn maintain_campfirelit_particles(
947        &mut self,
948        scene_data: &SceneData,
949        pos: Vec3<f32>,
950        vel: Option<&Vel>,
951    ) {
952        prof_span!("ParticleMgr::maintain_campfirelit_particles");
953        let time = scene_data.state.get_time();
954        let dt = scene_data.state.get_delta_time();
955        let mut rng = thread_rng();
956
957        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(50)) {
958            self.particles.push(Particle::new(
959                Duration::from_millis(250),
960                time,
961                ParticleMode::CampfireFire,
962                pos,
963            ));
964
965            self.particles.push(Particle::new(
966                Duration::from_secs(10),
967                time,
968                ParticleMode::CampfireSmoke,
969                pos.map(|e| e + thread_rng().gen_range(-0.25..0.25))
970                    + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.gen::<f32>()),
971            ));
972        }
973    }
974
975    fn maintain_barrel_organ_particles(
976        &mut self,
977        scene_data: &SceneData,
978        pos: Vec3<f32>,
979        vel: Option<&Vel>,
980    ) {
981        prof_span!("ParticleMgr::maintain_barrel_organ_particles");
982        let time = scene_data.state.get_time();
983        let dt = scene_data.state.get_delta_time();
984        let mut rng = thread_rng();
985
986        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(20)) {
987            self.particles.push(Particle::new(
988                Duration::from_millis(250),
989                time,
990                ParticleMode::BarrelOrgan,
991                pos,
992            ));
993
994            self.particles.push(Particle::new(
995                Duration::from_secs(10),
996                time,
997                ParticleMode::BarrelOrgan,
998                pos.map(|e| e + thread_rng().gen_range(-0.25..0.25))
999                    + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.gen::<f32>()),
1000            ));
1001        }
1002    }
1003
1004    fn maintain_boltfire_particles(
1005        &mut self,
1006        scene_data: &SceneData,
1007        pos: Vec3<f32>,
1008        vel: Option<&Vel>,
1009    ) {
1010        prof_span!("ParticleMgr::maintain_boltfire_particles");
1011        let time = scene_data.state.get_time();
1012        let dt = scene_data.state.get_delta_time();
1013        let mut rng = thread_rng();
1014
1015        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(4)) {
1016            self.particles.push(Particle::new(
1017                Duration::from_millis(500),
1018                time,
1019                ParticleMode::CampfireFire,
1020                pos,
1021            ));
1022            self.particles.push(Particle::new(
1023                Duration::from_secs(1),
1024                time,
1025                ParticleMode::CampfireSmoke,
1026                pos.map(|e| e + rng.gen_range(-0.25..0.25))
1027                    + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.gen::<f32>()),
1028            ));
1029        }
1030    }
1031
1032    fn maintain_boltfirebig_particles(
1033        &mut self,
1034        scene_data: &SceneData,
1035        pos: Vec3<f32>,
1036        vel: Option<&Vel>,
1037    ) {
1038        prof_span!("ParticleMgr::maintain_boltfirebig_particles");
1039        let time = scene_data.state.get_time();
1040        let dt = scene_data.state.get_delta_time();
1041        let mut rng = thread_rng();
1042
1043        // fire
1044        self.particles.resize_with(
1045            self.particles.len() + usize::from(self.scheduler.heartbeats(Duration::from_millis(2))),
1046            || {
1047                Particle::new(
1048                    Duration::from_millis(500),
1049                    time,
1050                    ParticleMode::CampfireFire,
1051                    pos.map(|e| e + rng.gen_range(-0.25..0.25))
1052                        + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.gen::<f32>()),
1053                )
1054            },
1055        );
1056
1057        // smoke
1058        self.particles.resize_with(
1059            self.particles.len() + usize::from(self.scheduler.heartbeats(Duration::from_millis(5))),
1060            || {
1061                Particle::new(
1062                    Duration::from_secs(2),
1063                    time,
1064                    ParticleMode::CampfireSmoke,
1065                    pos.map(|e| e + rng.gen_range(-0.25..0.25))
1066                        + vel.map_or(Vec3::zero(), |v| -v.0 * dt),
1067                )
1068            },
1069        );
1070    }
1071
1072    fn maintain_fireraindrop_particles(
1073        &mut self,
1074        scene_data: &SceneData,
1075        pos: Vec3<f32>,
1076        vel: Option<&Vel>,
1077    ) {
1078        prof_span!("ParticleMgr::maintain_fireraindrop_particles");
1079        let time = scene_data.state.get_time();
1080        let dt = scene_data.state.get_delta_time();
1081        let mut rng = thread_rng();
1082
1083        // trace
1084        self.particles.resize_with(
1085            self.particles.len()
1086                + usize::from(self.scheduler.heartbeats(Duration::from_millis(100))),
1087            || {
1088                Particle::new(
1089                    Duration::from_millis(300),
1090                    time,
1091                    ParticleMode::FieryDropletTrace,
1092                    pos.map(|e| e + rng.gen_range(-0.25..0.25))
1093                        + Vec3::new(0.0, 0.0, 0.5)
1094                        + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.gen::<f32>()),
1095                )
1096            },
1097        );
1098    }
1099
1100    fn maintain_boltnature_particles(
1101        &mut self,
1102        scene_data: &SceneData,
1103        pos: Vec3<f32>,
1104        vel: Option<&Vel>,
1105    ) {
1106        let time = scene_data.state.get_time();
1107        let dt = scene_data.state.get_delta_time();
1108        let mut rng = thread_rng();
1109
1110        // nature
1111        self.particles.resize_with(
1112            self.particles.len() + usize::from(self.scheduler.heartbeats(Duration::from_millis(2))),
1113            || {
1114                Particle::new(
1115                    Duration::from_millis(500),
1116                    time,
1117                    ParticleMode::CampfireSmoke,
1118                    pos.map(|e| e + rng.gen_range(-0.25..0.25))
1119                        + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.gen::<f32>()),
1120                )
1121            },
1122        );
1123    }
1124
1125    fn maintain_tornado_particles(&mut self, scene_data: &SceneData, pos: Vec3<f32>) {
1126        let time = scene_data.state.get_time();
1127        let mut rng = thread_rng();
1128
1129        // air particles
1130        self.particles.resize_with(
1131            self.particles.len() + usize::from(self.scheduler.heartbeats(Duration::from_millis(5))),
1132            || {
1133                Particle::new(
1134                    Duration::from_millis(1000),
1135                    time,
1136                    ParticleMode::Tornado,
1137                    pos.map(|e| e + rng.gen_range(-0.25..0.25)),
1138                )
1139            },
1140        );
1141    }
1142
1143    fn maintain_fiery_tornado_particles(&mut self, scene_data: &SceneData, pos: Vec3<f32>) {
1144        let time = scene_data.state.get_time();
1145        let mut rng = thread_rng();
1146
1147        // air particles
1148        self.particles.resize_with(
1149            self.particles.len() + usize::from(self.scheduler.heartbeats(Duration::from_millis(5))),
1150            || {
1151                Particle::new(
1152                    Duration::from_millis(1000),
1153                    time,
1154                    ParticleMode::FieryTornado,
1155                    pos.map(|e| e + rng.gen_range(-0.25..0.25)),
1156                )
1157            },
1158        );
1159    }
1160
1161    fn maintain_bomb_particles(
1162        &mut self,
1163        scene_data: &SceneData,
1164        pos: Vec3<f32>,
1165        vel: Option<&Vel>,
1166    ) {
1167        prof_span!("ParticleMgr::maintain_bomb_particles");
1168        let time = scene_data.state.get_time();
1169        let dt = scene_data.state.get_delta_time();
1170        let mut rng = thread_rng();
1171
1172        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(10)) {
1173            // sparks
1174            self.particles.push(Particle::new(
1175                Duration::from_millis(1500),
1176                time,
1177                ParticleMode::GunPowderSpark,
1178                pos,
1179            ));
1180
1181            // smoke
1182            self.particles.push(Particle::new(
1183                Duration::from_secs(2),
1184                time,
1185                ParticleMode::CampfireSmoke,
1186                pos + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.gen::<f32>()),
1187            ));
1188        }
1189    }
1190
1191    fn maintain_active_portal_particles(&mut self, scene_data: &SceneData, pos: Vec3<f32>) {
1192        prof_span!("ParticleMgr::maintain_active_portal_particles");
1193
1194        let time = scene_data.state.get_time();
1195        let mut rng = thread_rng();
1196
1197        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(5)) {
1198            let outer_pos =
1199                pos + (Vec2::unit_x().rotated_z(rng.gen_range((0.)..PI * 2.)) * 2.7).with_z(0.);
1200
1201            self.particles.push(Particle::new_directed(
1202                Duration::from_secs_f32(rng.gen_range(0.4..0.8)),
1203                time,
1204                ParticleMode::CultistFlame,
1205                outer_pos,
1206                outer_pos + Vec3::unit_z() * rng.gen_range(5.0..7.0),
1207            ));
1208        }
1209    }
1210
1211    fn maintain_portal_particles(&mut self, scene_data: &SceneData, pos: Vec3<f32>) {
1212        prof_span!("ParticleMgr::maintain_portal_particles");
1213
1214        let time = scene_data.state.get_time();
1215        let mut rng = thread_rng();
1216
1217        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(150)) {
1218            let outer_pos = pos
1219                + (Vec2::unit_x().rotated_z(rng.gen_range((0.)..PI * 2.))
1220                    * rng.gen_range(1.0..2.9))
1221                .with_z(0.);
1222
1223            self.particles.push(Particle::new_directed(
1224                Duration::from_secs_f32(rng.gen_range(0.5..3.0)),
1225                time,
1226                ParticleMode::CultistFlame,
1227                outer_pos,
1228                outer_pos + Vec3::unit_z() * rng.gen_range(3.0..4.0),
1229            ));
1230        }
1231    }
1232
1233    fn maintain_mine_particles(&mut self, scene_data: &SceneData, pos: Vec3<f32>) {
1234        prof_span!("ParticleMgr::maintain_mine_particles");
1235        let time = scene_data.state.get_time();
1236
1237        for _ in 0..self.scheduler.heartbeats(Duration::from_millis(1)) {
1238            // sparks
1239            self.particles.push(Particle::new(
1240                Duration::from_millis(25),
1241                time,
1242                ParticleMode::GunPowderSpark,
1243                pos,
1244            ));
1245        }
1246    }
1247
1248    fn maintain_char_state_particles(&mut self, scene_data: &SceneData, figure_mgr: &FigureMgr) {
1249        prof_span!("ParticleMgr::maintain_char_state_particles");
1250        let state = scene_data.state;
1251        let ecs = state.ecs();
1252        let time = state.get_time();
1253        let dt = scene_data.state.get_delta_time();
1254        let mut rng = thread_rng();
1255
1256        for (
1257            entity,
1258            interpolated,
1259            vel,
1260            character_state,
1261            body,
1262            ori,
1263            character_activity,
1264            physics,
1265            inventory,
1266        ) in (
1267            &ecs.entities(),
1268            &ecs.read_storage::<Interpolated>(),
1269            ecs.read_storage::<Vel>().maybe(),
1270            &ecs.read_storage::<CharacterState>(),
1271            &ecs.read_storage::<Body>(),
1272            &ecs.read_storage::<Ori>(),
1273            &ecs.read_storage::<CharacterActivity>(),
1274            &ecs.read_storage::<PhysicsState>(),
1275            ecs.read_storage::<Inventory>().maybe(),
1276        )
1277            .join()
1278        {
1279            match character_state {
1280                CharacterState::Boost(_) => {
1281                    self.particles.resize_with(
1282                        self.particles.len()
1283                            + usize::from(self.scheduler.heartbeats(Duration::from_millis(10))),
1284                        || {
1285                            Particle::new(
1286                                Duration::from_millis(250),
1287                                time,
1288                                ParticleMode::PortalFizz,
1289                                // Output particles from broom, not from character ass
1290                                interpolated.pos
1291                                    - ori.to_horizontal().look_dir().to_vec()
1292                                    - vel.map_or(Vec3::zero(), |v| v.0 * dt * rng.gen::<f32>()),
1293                            )
1294                        },
1295                    );
1296                },
1297                CharacterState::BasicMelee(c) => {
1298                    if let Some(specifier) = c.static_data.frontend_specifier {
1299                        match specifier {
1300                            states::basic_melee::FrontendSpecifier::FlameTornado => {
1301                                if matches!(c.stage_section, StageSection::Action) {
1302                                    let time = scene_data.state.get_time();
1303                                    let mut rng = thread_rng();
1304                                    self.particles.resize_with(
1305                                        self.particles.len()
1306                                            + 10
1307                                            + usize::from(
1308                                                self.scheduler.heartbeats(Duration::from_millis(5)),
1309                                            ),
1310                                        || {
1311                                            Particle::new(
1312                                                Duration::from_millis(1000),
1313                                                time,
1314                                                ParticleMode::FlameTornado,
1315                                                interpolated
1316                                                    .pos
1317                                                    .map(|e| e + rng.gen_range(-0.25..0.25)),
1318                                            )
1319                                        },
1320                                    );
1321                                }
1322                            },
1323                            states::basic_melee::FrontendSpecifier::FireGigasWhirlwind => {
1324                                if matches!(c.stage_section, StageSection::Action) {
1325                                    let time = scene_data.state.get_time();
1326                                    let mut rng = thread_rng();
1327                                    self.particles.resize_with(
1328                                        self.particles.len()
1329                                            + 3
1330                                            + usize::from(
1331                                                self.scheduler.heartbeats(Duration::from_millis(5)),
1332                                            ),
1333                                        || {
1334                                            Particle::new(
1335                                                Duration::from_millis(600),
1336                                                time,
1337                                                ParticleMode::FireGigasWhirlwind,
1338                                                interpolated
1339                                                    .pos
1340                                                    .map(|e| e + rng.gen_range(-0.25..0.25))
1341                                                    + 3.0 * Vec3::<f32>::unit_z(),
1342                                            )
1343                                        },
1344                                    );
1345                                }
1346                            },
1347                        }
1348                    }
1349                },
1350                CharacterState::RapidMelee(c) => {
1351                    if let Some(specifier) = c.static_data.frontend_specifier {
1352                        match specifier {
1353                            states::rapid_melee::FrontendSpecifier::CultistVortex => {
1354                                if matches!(c.stage_section, StageSection::Action) {
1355                                    let range = c.static_data.melee_constructor.range;
1356                                    // Particles for vortex
1357                                    let heartbeats =
1358                                        self.scheduler.heartbeats(Duration::from_millis(3));
1359                                    self.particles.resize_with(
1360                                        self.particles.len()
1361                                            + range.powi(2) as usize * usize::from(heartbeats)
1362                                                / 150,
1363                                        || {
1364                                            let rand_dist =
1365                                                range * (1.0 - rng.gen::<f32>().powi(10));
1366                                            let init_pos = Vec3::new(
1367                                                2.0 * rng.gen::<f32>() - 1.0,
1368                                                2.0 * rng.gen::<f32>() - 1.0,
1369                                                0.0,
1370                                            )
1371                                            .normalized()
1372                                                * rand_dist
1373                                                + interpolated.pos
1374                                                + Vec3::unit_z() * 0.05;
1375                                            Particle::new_directed(
1376                                                Duration::from_millis(900),
1377                                                time,
1378                                                ParticleMode::CultistFlame,
1379                                                init_pos,
1380                                                interpolated.pos,
1381                                            )
1382                                        },
1383                                    );
1384                                    // Particles for lifesteal effect
1385                                    for (_entity_b, interpolated_b, body_b, _health_b) in (
1386                                        &ecs.entities(),
1387                                        &ecs.read_storage::<Interpolated>(),
1388                                        &ecs.read_storage::<Body>(),
1389                                        &ecs.read_storage::<comp::Health>(),
1390                                    )
1391                                        .join()
1392                                        .filter(|(e, _, _, h)| !h.is_dead && entity != *e)
1393                                    {
1394                                        if interpolated.pos.distance_squared(interpolated_b.pos)
1395                                            < range.powi(2)
1396                                        {
1397                                            let heartbeats = self
1398                                                .scheduler
1399                                                .heartbeats(Duration::from_millis(20));
1400                                            self.particles.resize_with(
1401                                                self.particles.len()
1402                                                    + range.powi(2) as usize
1403                                                        * usize::from(heartbeats)
1404                                                        / 150,
1405                                                || {
1406                                                    let start_pos = interpolated_b.pos
1407                                                        + Vec3::unit_z() * body_b.height() * 0.5
1408                                                        + Vec3::<f32>::zero()
1409                                                            .map(|_| rng.gen_range(-1.0..1.0))
1410                                                            .normalized()
1411                                                            * 1.0;
1412                                                    Particle::new_directed(
1413                                                        Duration::from_millis(900),
1414                                                        time,
1415                                                        ParticleMode::CultistFlame,
1416                                                        start_pos,
1417                                                        interpolated.pos
1418                                                            + Vec3::unit_z() * body.height() * 0.5,
1419                                                    )
1420                                                },
1421                                            );
1422                                        }
1423                                    }
1424                                }
1425                            },
1426                            states::rapid_melee::FrontendSpecifier::IceWhirlwind => {
1427                                if matches!(c.stage_section, StageSection::Action) {
1428                                    let time = scene_data.state.get_time();
1429                                    let mut rng = thread_rng();
1430                                    self.particles.resize_with(
1431                                        self.particles.len()
1432                                            + 3
1433                                            + usize::from(
1434                                                self.scheduler.heartbeats(Duration::from_millis(5)),
1435                                            ),
1436                                        || {
1437                                            Particle::new(
1438                                                Duration::from_millis(1000),
1439                                                time,
1440                                                ParticleMode::IceWhirlwind,
1441                                                interpolated
1442                                                    .pos
1443                                                    .map(|e| e + rng.gen_range(-0.25..0.25)),
1444                                            )
1445                                        },
1446                                    );
1447                                }
1448                            },
1449                        }
1450                    }
1451                },
1452                CharacterState::RepeaterRanged(repeater) => {
1453                    if let Some(specifier) = repeater.static_data.specifier {
1454                        match specifier {
1455                            states::repeater_ranged::FrontendSpecifier::FireRainPhoenix => {
1456                                // base, dark clouds
1457                                self.particles.resize_with(
1458                                    self.particles.len()
1459                                        + 2 * usize::from(
1460                                            self.scheduler.heartbeats(Duration::from_millis(25)),
1461                                        ),
1462                                    || {
1463                                        let rand_pos = {
1464                                            let theta = rng.gen::<f32>() * TAU;
1465                                            let radius = repeater
1466                                                .static_data
1467                                                .properties_of_aoe
1468                                                .map(|aoe| aoe.radius)
1469                                                .unwrap_or_default()
1470                                                * rng.gen::<f32>().sqrt();
1471                                            let x = radius * theta.sin();
1472                                            let y = radius * theta.cos();
1473                                            Vec2::new(x, y) + interpolated.pos.xy()
1474                                        };
1475                                        let pos1 = rand_pos.with_z(
1476                                            repeater
1477                                                .static_data
1478                                                .properties_of_aoe
1479                                                .map(|aoe| aoe.height)
1480                                                .unwrap_or_default()
1481                                                + interpolated.pos.z
1482                                                + 2.0 * rng.gen::<f32>(),
1483                                        );
1484                                        Particle::new_directed(
1485                                            Duration::from_secs_f32(3.0),
1486                                            time,
1487                                            ParticleMode::PhoenixCloud,
1488                                            pos1,
1489                                            pos1 + Vec3::new(7.09, 4.09, 18.09),
1490                                        )
1491                                    },
1492                                );
1493                                self.particles.resize_with(
1494                                    self.particles.len()
1495                                        + 2 * usize::from(
1496                                            self.scheduler.heartbeats(Duration::from_millis(25)),
1497                                        ),
1498                                    || {
1499                                        let rand_pos = {
1500                                            let theta = rng.gen::<f32>() * TAU;
1501                                            let radius = repeater
1502                                                .static_data
1503                                                .properties_of_aoe
1504                                                .map(|aoe| aoe.radius)
1505                                                .unwrap_or_default()
1506                                                * rng.gen::<f32>().sqrt();
1507                                            let x = radius * theta.sin();
1508                                            let y = radius * theta.cos();
1509                                            Vec2::new(x, y) + interpolated.pos.xy()
1510                                        };
1511                                        let pos1 = rand_pos.with_z(
1512                                            repeater
1513                                                .static_data
1514                                                .properties_of_aoe
1515                                                .map(|aoe| aoe.height)
1516                                                .unwrap_or_default()
1517                                                + interpolated.pos.z
1518                                                + 1.5 * rng.gen::<f32>(),
1519                                        );
1520                                        Particle::new_directed(
1521                                            Duration::from_secs_f32(2.5),
1522                                            time,
1523                                            ParticleMode::PhoenixCloud,
1524                                            pos1,
1525                                            pos1 + Vec3::new(10.025, 4.025, 17.025),
1526                                        )
1527                                    },
1528                                );
1529                            },
1530                        }
1531                    }
1532                },
1533                CharacterState::Blink(c) => {
1534                    if let Some(specifier) = c.static_data.frontend_specifier {
1535                        match specifier {
1536                            states::blink::FrontendSpecifier::CultistFlame => {
1537                                self.particles.resize_with(
1538                                    self.particles.len()
1539                                        + usize::from(
1540                                            self.scheduler.heartbeats(Duration::from_millis(10)),
1541                                        ),
1542                                    || {
1543                                        let center_pos =
1544                                            interpolated.pos + Vec3::unit_z() * body.height() / 2.0;
1545                                        let outer_pos = interpolated.pos
1546                                            + Vec3::new(
1547                                                2.0 * rng.gen::<f32>() - 1.0,
1548                                                2.0 * rng.gen::<f32>() - 1.0,
1549                                                0.0,
1550                                            )
1551                                            .normalized()
1552                                                * (body.max_radius() + 2.0)
1553                                            + Vec3::unit_z() * body.height() * rng.gen::<f32>();
1554
1555                                        let (start_pos, end_pos) =
1556                                            if matches!(c.stage_section, StageSection::Buildup) {
1557                                                (outer_pos, center_pos)
1558                                            } else {
1559                                                (center_pos, outer_pos)
1560                                            };
1561
1562                                        Particle::new_directed(
1563                                            Duration::from_secs_f32(0.5),
1564                                            time,
1565                                            ParticleMode::CultistFlame,
1566                                            start_pos,
1567                                            end_pos,
1568                                        )
1569                                    },
1570                                );
1571                            },
1572                            states::blink::FrontendSpecifier::FlameThrower => {
1573                                self.particles.resize_with(
1574                                    self.particles.len()
1575                                        + usize::from(
1576                                            self.scheduler.heartbeats(Duration::from_millis(10)),
1577                                        ),
1578                                    || {
1579                                        let center_pos =
1580                                            interpolated.pos + Vec3::unit_z() * body.height() / 2.0;
1581                                        let outer_pos = interpolated.pos
1582                                            + Vec3::new(
1583                                                2.0 * rng.gen::<f32>() - 1.0,
1584                                                2.0 * rng.gen::<f32>() - 1.0,
1585                                                0.0,
1586                                            )
1587                                            .normalized()
1588                                                * (body.max_radius() + 2.0)
1589                                            + Vec3::unit_z() * body.height() * rng.gen::<f32>();
1590
1591                                        let (start_pos, end_pos) =
1592                                            if matches!(c.stage_section, StageSection::Buildup) {
1593                                                (outer_pos, center_pos)
1594                                            } else {
1595                                                (center_pos, outer_pos)
1596                                            };
1597
1598                                        Particle::new_directed(
1599                                            Duration::from_secs_f32(0.5),
1600                                            time,
1601                                            ParticleMode::FlameThrower,
1602                                            start_pos,
1603                                            end_pos,
1604                                        )
1605                                    },
1606                                );
1607                            },
1608                        }
1609                    }
1610                },
1611                CharacterState::SelfBuff(c) => {
1612                    if let Some(specifier) = c.static_data.specifier {
1613                        match specifier {
1614                            states::self_buff::FrontendSpecifier::FromTheAshes => {
1615                                if matches!(c.stage_section, StageSection::Action) {
1616                                    let pos = interpolated.pos;
1617                                    self.particles.resize_with(
1618                                        self.particles.len()
1619                                            + 2 * usize::from(
1620                                                self.scheduler.heartbeats(Duration::from_millis(1)),
1621                                            ),
1622                                        || {
1623                                            let start_pos = pos + Vec3::unit_z() - 1.0;
1624                                            let end_pos = pos
1625                                                + Vec3::new(
1626                                                    4.0 * rng.gen::<f32>() - 1.0,
1627                                                    4.0 * rng.gen::<f32>() - 1.0,
1628                                                    0.0,
1629                                                )
1630                                                .normalized()
1631                                                    * 1.5
1632                                                + Vec3::unit_z()
1633                                                + 5.0 * rng.gen::<f32>();
1634
1635                                            Particle::new_directed(
1636                                                Duration::from_secs_f32(0.5),
1637                                                time,
1638                                                ParticleMode::FieryBurst,
1639                                                start_pos,
1640                                                end_pos,
1641                                            )
1642                                        },
1643                                    );
1644                                    self.particles.resize_with(
1645                                        self.particles.len()
1646                                            + usize::from(
1647                                                self.scheduler
1648                                                    .heartbeats(Duration::from_millis(10)),
1649                                            ),
1650                                        || {
1651                                            Particle::new(
1652                                                Duration::from_millis(650),
1653                                                time,
1654                                                ParticleMode::FieryBurstVortex,
1655                                                pos.map(|e| e + rng.gen_range(-0.25..0.25))
1656                                                    + Vec3::new(0.0, 0.0, 1.0),
1657                                            )
1658                                        },
1659                                    );
1660                                    self.particles.resize_with(
1661                                        self.particles.len()
1662                                            + usize::from(
1663                                                self.scheduler
1664                                                    .heartbeats(Duration::from_millis(40)),
1665                                            ),
1666                                        || {
1667                                            Particle::new(
1668                                                Duration::from_millis(1000),
1669                                                time,
1670                                                ParticleMode::FieryBurstSparks,
1671                                                pos.map(|e| e + rng.gen_range(-0.25..0.25)),
1672                                            )
1673                                        },
1674                                    );
1675                                    self.particles.resize_with(
1676                                        self.particles.len()
1677                                            + usize::from(
1678                                                self.scheduler
1679                                                    .heartbeats(Duration::from_millis(14)),
1680                                            ),
1681                                        || {
1682                                            let pos1 = pos.map(|e| e + rng.gen_range(-0.25..0.25));
1683                                            Particle::new_directed(
1684                                                Duration::from_millis(1000),
1685                                                time,
1686                                                ParticleMode::FieryBurstAsh,
1687                                                pos1,
1688                                                Vec3::new(
1689                                                    4.5,    // radius of rand spawn
1690                                                    20.4,   // integer part - radius of the curve part, fractional part - relative time of setting particle on fire
1691                                                    8.58)   // height of the flight
1692                                                    + pos1,
1693                                            )
1694                                        },
1695                                    );
1696                                }
1697                            },
1698                        }
1699                    }
1700                    use buff::BuffKind;
1701                    if c.static_data
1702                        .buffs
1703                        .iter()
1704                        .any(|buff_desc| matches!(buff_desc.kind, BuffKind::Frenzied))
1705                        && matches!(c.stage_section, StageSection::Action)
1706                    {
1707                        self.particles.resize_with(
1708                            self.particles.len()
1709                                + usize::from(self.scheduler.heartbeats(Duration::from_millis(5))),
1710                            || {
1711                                let start_pos = interpolated.pos
1712                                    + Vec3::new(
1713                                        body.max_radius(),
1714                                        body.max_radius(),
1715                                        body.height() / 2.0,
1716                                    )
1717                                    .map(|d| d * rng.gen_range(-1.0..1.0));
1718                                let end_pos =
1719                                    interpolated.pos + (start_pos - interpolated.pos) * 6.0;
1720                                Particle::new_directed(
1721                                    Duration::from_secs(1),
1722                                    time,
1723                                    ParticleMode::Enraged,
1724                                    start_pos,
1725                                    end_pos,
1726                                )
1727                            },
1728                        );
1729                    }
1730                },
1731                CharacterState::BasicBeam(beam) => {
1732                    let ori = *ori;
1733                    let _look_dir = *character_activity.look_dir.unwrap_or(ori.look_dir());
1734                    let dir = ori.look_dir(); //.with_z(look_dir.z);
1735                    let specifier = beam.static_data.specifier;
1736                    if specifier == beam::FrontendSpecifier::PhoenixLaser
1737                        && matches!(beam.stage_section, StageSection::Buildup)
1738                    {
1739                        self.particles.resize_with(
1740                            self.particles.len()
1741                                + 2 * usize::from(
1742                                    self.scheduler.heartbeats(Duration::from_millis(2)),
1743                                ),
1744                            || {
1745                                let mut left_right_alignment =
1746                                    dir.cross(Vec3::new(0.0, 0.0, 1.0)).normalized();
1747                                if rng.gen_bool(0.5) {
1748                                    left_right_alignment *= -1.0;
1749                                }
1750                                let start = interpolated.pos
1751                                    + left_right_alignment * 4.0
1752                                    + dir.normalized() * 6.0;
1753                                let lifespan = Duration::from_secs_f32(0.5);
1754                                Particle::new_directed(
1755                                    lifespan,
1756                                    time,
1757                                    ParticleMode::PhoenixBuildUpAim,
1758                                    start,
1759                                    interpolated.pos
1760                                        + dir.normalized() * 3.0
1761                                        + left_right_alignment * 0.4
1762                                        + vel
1763                                            .map_or(Vec3::zero(), |v| v.0 * lifespan.as_secs_f32()),
1764                                )
1765                            },
1766                        );
1767                    }
1768                },
1769                CharacterState::Glide(glide) => {
1770                    if let Some(Fluid::Air {
1771                        vel: air_vel,
1772                        elevation: _,
1773                    }) = physics.in_fluid
1774                    {
1775                        // Empirical observation is that air_vel is somewhere
1776                        // between 0.0 and 13.0, but we are extending to be sure
1777                        const MAX_AIR_VEL: f32 = 15.0;
1778                        const MIN_AIR_VEL: f32 = -2.0;
1779
1780                        let minmax_norm = |val, min, max| (val - min) / (max - min);
1781
1782                        let wind_speed = air_vel.0.magnitude();
1783
1784                        // Less means more frequent particles
1785                        let heartbeat = 200
1786                            - Lerp::lerp(
1787                                50u64,
1788                                150,
1789                                minmax_norm(wind_speed, MIN_AIR_VEL, MAX_AIR_VEL),
1790                            );
1791
1792                        let new_count = self.particles.len()
1793                            + usize::from(
1794                                self.scheduler.heartbeats(Duration::from_millis(heartbeat)),
1795                            );
1796
1797                        // More number, longer particles
1798                        let duration = Lerp::lerp(
1799                            0u64,
1800                            1000,
1801                            minmax_norm(wind_speed, MIN_AIR_VEL, MAX_AIR_VEL),
1802                        );
1803                        let duration = Duration::from_millis(duration);
1804
1805                        self.particles.resize_with(new_count, || {
1806                            let start_pos = interpolated.pos
1807                                + Vec3::new(
1808                                    body.max_radius(),
1809                                    body.max_radius(),
1810                                    body.height() / 2.0,
1811                                )
1812                                .map(|d| d * rng.gen_range(-10.0..10.0));
1813
1814                            Particle::new_directed(
1815                                duration,
1816                                time,
1817                                ParticleMode::Airflow,
1818                                start_pos,
1819                                start_pos + air_vel.0,
1820                            )
1821                        });
1822
1823                        // When using the glide boost, emit particles
1824                        if let Some(states::glide::Boost::Forward(_)) = &glide.booster
1825                            && let Some(figure_state) =
1826                                figure_mgr.states.character_states.get(&entity)
1827                            && let Some(tp0) = figure_state.primary_abs_trail_points
1828                            && let Some(tp1) = figure_state.secondary_abs_trail_points
1829                        {
1830                            for _ in 0..self.scheduler.heartbeats(Duration::from_millis(5)) {
1831                                self.particles.push(Particle::new(
1832                                    Duration::from_secs(2),
1833                                    time,
1834                                    ParticleMode::EngineJet,
1835                                    ((tp0.0 + tp1.1) * 0.5)
1836                                        // TODO: This offset is used to position the particles at the engine outlet. Ideally, we'd have a way to configure this per-glider
1837                                        + Vec3::unit_z() * 0.5
1838                                        + Vec3::<f32>::zero().map(|_| rng.gen_range(-0.25..0.25))
1839                                        + vel.map_or(Vec3::zero(), |v| -v.0 * dt * rng.gen::<f32>()),
1840                                ));
1841                            }
1842                        }
1843                    }
1844                },
1845                CharacterState::Transform(data) => {
1846                    if matches!(data.stage_section, StageSection::Buildup)
1847                        && let Some(specifier) = data.static_data.specifier
1848                    {
1849                        match specifier {
1850                            states::transform::FrontendSpecifier::Evolve => {
1851                                self.particles.resize_with(
1852                                    self.particles.len()
1853                                        + usize::from(
1854                                            self.scheduler.heartbeats(Duration::from_millis(10)),
1855                                        ),
1856                                    || {
1857                                        let start_pos = interpolated.pos
1858                                            + (Vec2::unit_y()
1859                                                * rng.gen::<f32>()
1860                                                * body.max_radius())
1861                                            .rotated_z(rng.gen_range(0.0..(PI * 2.0)))
1862                                            .with_z(body.height() * rng.gen::<f32>());
1863
1864                                        Particle::new_directed(
1865                                            Duration::from_millis(100),
1866                                            time,
1867                                            ParticleMode::BarrelOrgan,
1868                                            start_pos,
1869                                            start_pos + Vec3::unit_z() * 2.0,
1870                                        )
1871                                    },
1872                                )
1873                            },
1874                            states::transform::FrontendSpecifier::Cursekeeper => {
1875                                self.particles.resize_with(
1876                                    self.particles.len()
1877                                        + usize::from(
1878                                            self.scheduler.heartbeats(Duration::from_millis(10)),
1879                                        ),
1880                                    || {
1881                                        let start_pos = interpolated.pos
1882                                            + (Vec2::unit_y()
1883                                                * rng.gen::<f32>()
1884                                                * body.max_radius())
1885                                            .rotated_z(rng.gen_range(0.0..(PI * 2.0)))
1886                                            .with_z(body.height() * rng.gen::<f32>());
1887
1888                                        Particle::new_directed(
1889                                            Duration::from_millis(100),
1890                                            time,
1891                                            ParticleMode::FireworkPurple,
1892                                            start_pos,
1893                                            start_pos + Vec3::unit_z() * 2.0,
1894                                        )
1895                                    },
1896                                )
1897                            },
1898                        }
1899                    }
1900                },
1901                CharacterState::ChargedMelee(_melee) => {
1902                    self.maintain_hydra_tail_swipe_particles(
1903                        scene_data,
1904                        figure_mgr,
1905                        entity,
1906                        interpolated.pos,
1907                        body,
1908                        character_state,
1909                        inventory,
1910                    );
1911                },
1912                _ => {},
1913            }
1914        }
1915    }
1916
1917    fn maintain_beam_particles(&mut self, scene_data: &SceneData, lights: &mut Vec<Light>) {
1918        let state = scene_data.state;
1919        let ecs = state.ecs();
1920        let time = state.get_time();
1921        let terrain = state.terrain();
1922        // Limit to 100 per tick, so at less than 10 FPS particle generation
1923        // work doesn't increase frame cost further.
1924        let tick_elapse = u32::from(self.scheduler.heartbeats(Duration::from_millis(1)).min(100));
1925        let mut rng = thread_rng();
1926
1927        for (beam, ori) in (&ecs.read_storage::<Beam>(), &ecs.read_storage::<Ori>()).join() {
1928            let particles_per_sec = (match beam.specifier {
1929                beam::FrontendSpecifier::Flamethrower
1930                | beam::FrontendSpecifier::Bubbles
1931                | beam::FrontendSpecifier::Steam
1932                | beam::FrontendSpecifier::Frost
1933                | beam::FrontendSpecifier::Poison
1934                | beam::FrontendSpecifier::Ink
1935                | beam::FrontendSpecifier::PhoenixLaser
1936                | beam::FrontendSpecifier::Gravewarden => 300.0,
1937                beam::FrontendSpecifier::FirePillar | beam::FrontendSpecifier::FlameWallPillar => {
1938                    40.0 * beam.end_radius.powi(2)
1939                },
1940                beam::FrontendSpecifier::LifestealBeam => 420.0,
1941                beam::FrontendSpecifier::Cultist => 960.0,
1942                beam::FrontendSpecifier::WebStrand => 180.0,
1943                beam::FrontendSpecifier::Lightning => 120.0,
1944                beam::FrontendSpecifier::FireGigasOverheat => 1600.0,
1945            }) / 1000.0;
1946
1947            let beam_tick_count = tick_elapse as f32 * particles_per_sec;
1948            let beam_tick_count = if rng.gen_bool(f64::from(beam_tick_count.fract())) {
1949                beam_tick_count.ceil() as u32
1950            } else {
1951                beam_tick_count.floor() as u32
1952            };
1953
1954            if beam_tick_count == 0 {
1955                continue;
1956            }
1957
1958            let distributed_time = tick_elapse as f64 / (beam_tick_count * 1000) as f64;
1959            let angle = (beam.end_radius / beam.range).atan();
1960            let beam_dir = (beam.bezier.ctrl - beam.bezier.start)
1961                .try_normalized()
1962                .unwrap_or(*ori.look_dir());
1963            let raycast_distance = |from, to| terrain.ray(from, to).until(Block::is_solid).cast().0;
1964
1965            self.particles.reserve(beam_tick_count as usize);
1966            match beam.specifier {
1967                beam::FrontendSpecifier::Flamethrower => {
1968                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
1969                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
1970                    // Emit a light when using flames
1971                    if scene_data.flashing_lights_enabled {
1972                        lights.push(Light::new(
1973                            beam.bezier.start,
1974                            Rgb::new(1.0, 0.25, 0.05).map(|e| e * rng.gen_range(0.8..1.2)),
1975                            2.0,
1976                        ));
1977                    }
1978
1979                    for i in 0..beam_tick_count {
1980                        let phi: f32 = rng.gen_range(0.0..angle);
1981                        let theta: f32 = rng.gen_range(0.0..2.0 * PI);
1982                        let offset_z =
1983                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
1984                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
1985                        self.particles.push(Particle::new_directed_with_collision(
1986                            Duration::from_secs_f64(beam.duration.0),
1987                            time + distributed_time * i as f64,
1988                            ParticleMode::FlameThrower,
1989                            beam.bezier.start,
1990                            beam.bezier.start + random_ori * beam.range,
1991                            raycast_distance,
1992                        ));
1993                    }
1994                },
1995                beam::FrontendSpecifier::FireGigasOverheat => {
1996                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
1997                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
1998                    // Emit a light when using flames
1999                    if scene_data.flashing_lights_enabled {
2000                        lights.push(Light::new(
2001                            beam.bezier.start,
2002                            Rgb::new(1.0, 0.25, 0.05).map(|e| e * rng.gen_range(0.8..1.2)),
2003                            2.0,
2004                        ));
2005                    }
2006
2007                    for i in 0..beam_tick_count {
2008                        let phi: f32 = rng.gen_range(0.0..angle);
2009                        let theta: f32 = rng.gen_range(0.0..2.0 * PI);
2010                        let offset_z =
2011                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
2012                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2013                        self.particles.push(Particle::new_directed_with_collision(
2014                            Duration::from_secs_f64(beam.duration.0),
2015                            time + distributed_time * i as f64,
2016                            ParticleMode::FireGigasOverheat,
2017                            beam.bezier.start,
2018                            beam.bezier.start + random_ori * beam.range,
2019                            raycast_distance,
2020                        ));
2021                    }
2022                },
2023                beam::FrontendSpecifier::FirePillar | beam::FrontendSpecifier::FlameWallPillar => {
2024                    // Emit a light when using flames
2025                    if scene_data.flashing_lights_enabled {
2026                        lights.push(Light::new(
2027                            beam.bezier.start,
2028                            Rgb::new(1.0, 0.25, 0.05).map(|e| e * rng.gen_range(0.8..1.2)),
2029                            2.0,
2030                        ));
2031                    }
2032
2033                    for i in 0..beam_tick_count {
2034                        let theta: f32 = rng.gen_range(0.0..2.0 * PI);
2035                        let radius = beam.start_radius * (1.0 - rng.gen::<f32>().powi(8));
2036                        let offset = Vec3::new(radius * theta.cos(), radius * theta.sin(), 0.0);
2037                        self.particles.push(Particle::new_directed_with_collision(
2038                            Duration::from_secs_f64(beam.duration.0),
2039                            time + distributed_time * i as f64,
2040                            ParticleMode::FirePillar,
2041                            beam.bezier.start + offset,
2042                            beam.bezier.start + offset + beam.range * Vec3::unit_z(),
2043                            raycast_distance,
2044                        ));
2045                    }
2046                },
2047                beam::FrontendSpecifier::Cultist => {
2048                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
2049                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
2050                    // Emit a light when using flames
2051                    if scene_data.flashing_lights_enabled {
2052                        lights.push(Light::new(
2053                            beam.bezier.start,
2054                            Rgb::new(1.0, 0.0, 1.0).map(|e| e * rng.gen_range(0.5..1.0)),
2055                            2.0,
2056                        ));
2057                    }
2058                    for i in 0..beam_tick_count {
2059                        let phi: f32 = rng.gen_range(0.0..angle);
2060                        let theta: f32 = rng.gen_range(0.0..2.0 * PI);
2061                        let offset_z =
2062                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
2063                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2064                        self.particles.push(Particle::new_directed_with_collision(
2065                            Duration::from_secs_f64(beam.duration.0),
2066                            time + distributed_time * i as f64,
2067                            ParticleMode::CultistFlame,
2068                            beam.bezier.start,
2069                            beam.bezier.start + random_ori * beam.range,
2070                            raycast_distance,
2071                        ));
2072                    }
2073                },
2074                beam::FrontendSpecifier::LifestealBeam => {
2075                    // Emit a light when using lifesteal beam
2076                    if scene_data.flashing_lights_enabled {
2077                        lights.push(Light::new(beam.bezier.start, Rgb::new(0.8, 1.0, 0.5), 1.0));
2078                    }
2079
2080                    // If the beam is one straight line, we can run raycast one time.
2081                    let bezier_end = beam.bezier.start + beam_dir * beam.range;
2082                    let distance = raycast_distance(beam.bezier.start, bezier_end);
2083                    for i in 0..beam_tick_count {
2084                        self.particles.push(Particle::new_directed_with_collision(
2085                            Duration::from_secs_f64(beam.duration.0),
2086                            time + distributed_time * i as f64,
2087                            ParticleMode::LifestealBeam,
2088                            beam.bezier.start,
2089                            bezier_end,
2090                            |_from, _to| distance,
2091                        ));
2092                    }
2093                },
2094                beam::FrontendSpecifier::Gravewarden => {
2095                    for i in 0..beam_tick_count {
2096                        let mut offset = 0.5;
2097                        let side = Vec2::new(-beam_dir.y, beam_dir.x);
2098                        self.particles.resize_with(self.particles.len() + 2, || {
2099                            offset = -offset;
2100                            Particle::new_directed_with_collision(
2101                                Duration::from_secs_f64(beam.duration.0),
2102                                time + distributed_time * i as f64,
2103                                ParticleMode::Laser,
2104                                beam.bezier.start + beam_dir * 1.5 + side * offset,
2105                                beam.bezier.start + beam_dir * beam.range + side * offset,
2106                                raycast_distance,
2107                            )
2108                        });
2109                    }
2110                },
2111                beam::FrontendSpecifier::WebStrand => {
2112                    let bezier_end = beam.bezier.start + beam_dir * beam.range;
2113                    let distance = raycast_distance(beam.bezier.start, bezier_end);
2114                    for i in 0..beam_tick_count {
2115                        self.particles.push(Particle::new_directed_with_collision(
2116                            Duration::from_secs_f64(beam.duration.0),
2117                            time + distributed_time * i as f64,
2118                            ParticleMode::WebStrand,
2119                            beam.bezier.start,
2120                            bezier_end,
2121                            |_from, _to| distance,
2122                        ));
2123                    }
2124                },
2125                beam::FrontendSpecifier::Bubbles => {
2126                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
2127                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
2128                    for i in 0..beam_tick_count {
2129                        let phi: f32 = rng.gen_range(0.0..angle);
2130                        let theta: f32 = rng.gen_range(0.0..2.0 * PI);
2131                        let offset_z =
2132                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
2133                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2134                        self.particles.push(Particle::new_directed_with_collision(
2135                            Duration::from_secs_f64(beam.duration.0),
2136                            time + distributed_time * i as f64,
2137                            ParticleMode::Bubbles,
2138                            beam.bezier.start,
2139                            beam.bezier.start + random_ori * beam.range,
2140                            raycast_distance,
2141                        ));
2142                    }
2143                },
2144                beam::FrontendSpecifier::Poison => {
2145                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
2146                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
2147                    for i in 0..beam_tick_count {
2148                        let phi: f32 = rng.gen_range(0.0..angle);
2149                        let theta: f32 = rng.gen_range(0.0..2.0 * PI);
2150                        let offset_z =
2151                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
2152                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2153                        self.particles.push(Particle::new_directed_with_collision(
2154                            Duration::from_secs_f64(beam.duration.0),
2155                            time + distributed_time * i as f64,
2156                            ParticleMode::Poison,
2157                            beam.bezier.start,
2158                            beam.bezier.start + random_ori * beam.range,
2159                            raycast_distance,
2160                        ));
2161                    }
2162                },
2163                beam::FrontendSpecifier::Ink => {
2164                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
2165                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
2166                    for i in 0..beam_tick_count {
2167                        let phi: f32 = rng.gen_range(0.0..angle);
2168                        let theta: f32 = rng.gen_range(0.0..2.0 * PI);
2169                        let offset_z =
2170                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
2171                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2172                        self.particles.push(Particle::new_directed_with_collision(
2173                            Duration::from_secs_f64(beam.duration.0),
2174                            time + distributed_time * i as f64,
2175                            ParticleMode::Bubbles,
2176                            beam.bezier.start,
2177                            beam.bezier.start + random_ori * beam.range,
2178                            raycast_distance,
2179                        ));
2180                    }
2181                },
2182                beam::FrontendSpecifier::Steam => {
2183                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
2184                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
2185                    for i in 0..beam_tick_count {
2186                        let phi: f32 = rng.gen_range(0.0..angle);
2187                        let theta: f32 = rng.gen_range(0.0..2.0 * PI);
2188                        let offset_z =
2189                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
2190                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2191                        self.particles.push(Particle::new_directed_with_collision(
2192                            Duration::from_secs_f64(beam.duration.0),
2193                            time + distributed_time * i as f64,
2194                            ParticleMode::Steam,
2195                            beam.bezier.start,
2196                            beam.bezier.start + random_ori * beam.range,
2197                            raycast_distance,
2198                        ));
2199                    }
2200                },
2201                beam::FrontendSpecifier::Lightning => {
2202                    let bezier_end = beam.bezier.start + beam_dir * beam.range;
2203                    let distance = raycast_distance(beam.bezier.start, bezier_end);
2204                    for i in 0..beam_tick_count {
2205                        self.particles.push(Particle::new_directed_with_collision(
2206                            Duration::from_secs_f64(beam.duration.0),
2207                            time + distributed_time * i as f64,
2208                            ParticleMode::Lightning,
2209                            beam.bezier.start,
2210                            bezier_end,
2211                            |_from, _to| distance,
2212                        ));
2213                    }
2214                },
2215                beam::FrontendSpecifier::Frost => {
2216                    let (from, to) = (Vec3::<f32>::unit_z(), beam_dir);
2217                    let m = Mat3::<f32>::rotation_from_to_3d(from, to);
2218                    for i in 0..beam_tick_count {
2219                        let phi: f32 = rng.gen_range(0.0..angle);
2220                        let theta: f32 = rng.gen_range(0.0..2.0 * PI);
2221                        let offset_z =
2222                            Vec3::new(phi.sin() * theta.cos(), phi.sin() * theta.sin(), phi.cos());
2223                        let random_ori = offset_z * m * Vec3::new(-1.0, -1.0, 1.0);
2224                        self.particles.push(Particle::new_directed_with_collision(
2225                            Duration::from_secs_f64(beam.duration.0),
2226                            time + distributed_time * i as f64,
2227                            ParticleMode::Ice,
2228                            beam.bezier.start,
2229                            beam.bezier.start + random_ori * beam.range,
2230                            raycast_distance,
2231                        ));
2232                    }
2233                },
2234                beam::FrontendSpecifier::PhoenixLaser => {
2235                    let bezier_end = beam.bezier.start + beam_dir * beam.range;
2236                    let distance = raycast_distance(beam.bezier.start, bezier_end);
2237                    for i in 0..beam_tick_count {
2238                        self.particles.push(Particle::new_directed_with_collision(
2239                            Duration::from_secs_f64(beam.duration.0),
2240                            time + distributed_time * i as f64,
2241                            ParticleMode::PhoenixBeam,
2242                            beam.bezier.start,
2243                            bezier_end,
2244                            |_from, _to| distance,
2245                        ));
2246                    }
2247                },
2248            }
2249        }
2250    }
2251
2252    fn maintain_aura_particles(&mut self, scene_data: &SceneData) {
2253        let state = scene_data.state;
2254        let ecs = state.ecs();
2255        let time = state.get_time();
2256        let mut rng = thread_rng();
2257        let dt = scene_data.state.get_delta_time();
2258
2259        for (interp, pos, auras, body_maybe) in (
2260            ecs.read_storage::<Interpolated>().maybe(),
2261            &ecs.read_storage::<Pos>(),
2262            &ecs.read_storage::<comp::Auras>(),
2263            ecs.read_storage::<comp::Body>().maybe(),
2264        )
2265            .join()
2266        {
2267            let pos = interp.map_or(pos.0, |i| i.pos);
2268
2269            for (_, aura) in auras.auras.iter() {
2270                match aura.aura_kind {
2271                    aura::AuraKind::Buff {
2272                        kind: buff::BuffKind::ProtectingWard,
2273                        ..
2274                    } => {
2275                        let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
2276                        self.particles.resize_with(
2277                            self.particles.len()
2278                                + aura.radius.powi(2) as usize * usize::from(heartbeats) / 300,
2279                            || {
2280                                let rand_dist = aura.radius * (1.0 - rng.gen::<f32>().powi(100));
2281                                let init_pos = Vec3::new(rand_dist, 0_f32, 0_f32);
2282                                let duration = Duration::from_secs_f64(
2283                                    aura.end_time
2284                                        .map_or(1.0, |end| end.0 - time)
2285                                        .clamp(0.0, 1.0),
2286                                );
2287                                Particle::new_directed(
2288                                    duration,
2289                                    time,
2290                                    ParticleMode::EnergyNature,
2291                                    pos,
2292                                    pos + init_pos,
2293                                )
2294                            },
2295                        );
2296                    },
2297                    aura::AuraKind::Buff {
2298                        kind: buff::BuffKind::Regeneration,
2299                        ..
2300                    } => {
2301                        if auras.auras.iter().any(|(_, aura)| {
2302                            matches!(aura.aura_kind, aura::AuraKind::Buff {
2303                                kind: buff::BuffKind::ProtectingWard,
2304                                ..
2305                            })
2306                        }) {
2307                            // If same entity has both protecting ward and regeneration auras, skip
2308                            // particles for regeneration
2309                            continue;
2310                        }
2311                        let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
2312                        self.particles.resize_with(
2313                            self.particles.len()
2314                                + aura.radius.powi(2) as usize * usize::from(heartbeats) / 300,
2315                            || {
2316                                let rand_dist = aura.radius * (1.0 - rng.gen::<f32>().powi(100));
2317                                let init_pos = Vec3::new(rand_dist, 0_f32, 0_f32);
2318                                let duration = Duration::from_secs_f64(
2319                                    aura.end_time
2320                                        .map_or(1.0, |end| end.0 - time)
2321                                        .clamp(0.0, 1.0),
2322                                );
2323                                Particle::new_directed(
2324                                    duration,
2325                                    time,
2326                                    ParticleMode::EnergyHealing,
2327                                    pos,
2328                                    pos + init_pos,
2329                                )
2330                            },
2331                        );
2332                    },
2333                    aura::AuraKind::Buff {
2334                        kind: buff::BuffKind::Burning,
2335                        ..
2336                    } => {
2337                        let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
2338                        self.particles.resize_with(
2339                            self.particles.len()
2340                                + aura.radius.powi(2) as usize * usize::from(heartbeats) / 300,
2341                            || {
2342                                let rand_pos = {
2343                                    let theta = rng.gen::<f32>() * TAU;
2344                                    let radius = aura.radius * rng.gen::<f32>().sqrt();
2345                                    let x = radius * theta.sin();
2346                                    let y = radius * theta.cos();
2347                                    Vec2::new(x, y) + pos.xy()
2348                                };
2349                                let duration = Duration::from_secs_f64(
2350                                    aura.end_time
2351                                        .map_or(1.0, |end| end.0 - time)
2352                                        .clamp(0.0, 1.0),
2353                                );
2354                                Particle::new_directed(
2355                                    duration,
2356                                    time,
2357                                    ParticleMode::FlameThrower,
2358                                    rand_pos.with_z(pos.z),
2359                                    rand_pos.with_z(pos.z + 1.0),
2360                                )
2361                            },
2362                        );
2363                    },
2364                    aura::AuraKind::Buff {
2365                        kind: buff::BuffKind::Hastened,
2366                        ..
2367                    } => {
2368                        let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
2369                        self.particles.resize_with(
2370                            self.particles.len()
2371                                + aura.radius.powi(2) as usize * usize::from(heartbeats) / 300,
2372                            || {
2373                                let rand_dist = aura.radius * (1.0 - rng.gen::<f32>().powi(100));
2374                                let init_pos = Vec3::new(rand_dist, 0_f32, 0_f32);
2375                                let duration = Duration::from_secs_f64(
2376                                    aura.end_time
2377                                        .map_or(1.0, |end| end.0 - time)
2378                                        .clamp(0.0, 1.0),
2379                                );
2380                                Particle::new_directed(
2381                                    duration,
2382                                    time,
2383                                    ParticleMode::EnergyBuffing,
2384                                    pos,
2385                                    pos + init_pos,
2386                                )
2387                            },
2388                        );
2389                    },
2390                    aura::AuraKind::Buff {
2391                        kind: buff::BuffKind::Frozen,
2392                        ..
2393                    } => {
2394                        let is_new_aura = aura.data.duration.is_none_or(|max_dur| {
2395                            let rem_dur = aura.end_time.map_or(time, |e| e.0) - time;
2396                            rem_dur > max_dur.0 * 0.9
2397                        });
2398                        if is_new_aura {
2399                            let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
2400                            self.particles.resize_with(
2401                                self.particles.len()
2402                                    + aura.radius.powi(2) as usize * usize::from(heartbeats) / 300,
2403                                || {
2404                                    let rand_angle = rng.gen_range(0.0..TAU);
2405                                    let offset =
2406                                        Vec2::new(rand_angle.cos(), rand_angle.sin()) * aura.radius;
2407                                    let z_start = body_maybe
2408                                        .map_or(0.0, |b| rng.gen_range(0.5..0.75) * b.height());
2409                                    let z_end = body_maybe
2410                                        .map_or(0.0, |b| rng.gen_range(0.0..3.0) * b.height());
2411                                    Particle::new_directed(
2412                                        Duration::from_secs(3),
2413                                        time,
2414                                        ParticleMode::Ice,
2415                                        pos + Vec3::unit_z() * z_start,
2416                                        pos + offset.with_z(z_end),
2417                                    )
2418                                },
2419                            );
2420                        }
2421                    },
2422                    aura::AuraKind::Buff {
2423                        kind: buff::BuffKind::Heatstroke,
2424                        ..
2425                    } => {
2426                        let heartbeats = self.scheduler.heartbeats(Duration::from_millis(5));
2427                        self.particles.resize_with(
2428                            self.particles.len()
2429                                + aura.radius.powi(2) as usize * usize::from(heartbeats) / 900,
2430                            || {
2431                                let rand_dist = aura.radius * (1.0 - rng.gen::<f32>().powi(100));
2432                                let init_pos = Vec3::new(rand_dist, 0_f32, 0_f32);
2433                                let duration = Duration::from_secs_f64(
2434                                    aura.end_time
2435                                        .map_or(1.0, |end| end.0 - time)
2436                                        .clamp(0.0, 1.0),
2437                                );
2438                                Particle::new_directed(
2439                                    duration,
2440                                    time,
2441                                    ParticleMode::EnergyPhoenix,
2442                                    pos,
2443                                    pos + init_pos,
2444                                )
2445                            },
2446                        );
2447
2448                        let num_particles = aura.radius.powi(2) * dt / 50.0;
2449                        let num_particles = num_particles.floor() as usize
2450                            + usize::from(rng.gen_bool(f64::from(num_particles % 1.0)));
2451                        self.particles
2452                            .resize_with(self.particles.len() + num_particles, || {
2453                                let rand_pos = {
2454                                    let theta = rng.gen::<f32>() * TAU;
2455                                    let radius = aura.radius * rng.gen::<f32>().sqrt();
2456                                    let x = radius * theta.sin();
2457                                    let y = radius * theta.cos();
2458                                    Vec2::new(x, y) + pos.xy()
2459                                };
2460                                let duration = Duration::from_secs_f64(
2461                                    aura.end_time
2462                                        .map_or(1.0, |end| end.0 - time)
2463                                        .clamp(0.0, 1.0),
2464                                );
2465                                Particle::new_directed(
2466                                    duration,
2467                                    time,
2468                                    ParticleMode::FieryBurstAsh,
2469                                    pos,
2470                                    Vec3::new(
2471                                                    0.0,    // radius of rand spawn
2472                                                    20.0,   // integer part - radius of the curve part, fractional part - relative time of setting particle on fire
2473                                                    5.5)    // height of the flight
2474                                                    + rand_pos.with_z(pos.z),
2475                                )
2476                            });
2477                    },
2478                    _ => {},
2479                }
2480            }
2481        }
2482    }
2483
2484    fn maintain_buff_particles(&mut self, scene_data: &SceneData) {
2485        let state = scene_data.state;
2486        let ecs = state.ecs();
2487        let time = state.get_time();
2488        let mut rng = thread_rng();
2489
2490        for (interp, pos, buffs, body, ori, scale) in (
2491            ecs.read_storage::<Interpolated>().maybe(),
2492            &ecs.read_storage::<Pos>(),
2493            &ecs.read_storage::<comp::Buffs>(),
2494            &ecs.read_storage::<Body>(),
2495            &ecs.read_storage::<Ori>(),
2496            ecs.read_storage::<Scale>().maybe(),
2497        )
2498            .join()
2499        {
2500            let pos = interp.map_or(pos.0, |i| i.pos);
2501
2502            for (buff_kind, buff_keys) in buffs
2503                .kinds
2504                .iter()
2505                .filter_map(|(kind, keys)| keys.as_ref().map(|keys| (kind, keys)))
2506            {
2507                use buff::BuffKind;
2508                match buff_kind {
2509                    BuffKind::Cursed | BuffKind::Burning => {
2510                        self.particles.resize_with(
2511                            self.particles.len()
2512                                + usize::from(self.scheduler.heartbeats(Duration::from_millis(15))),
2513                            || {
2514                                let start_pos = pos
2515                                    + Vec3::unit_z() * body.height() * 0.25
2516                                    + Vec3::<f32>::zero()
2517                                        .map(|_| rng.gen_range(-1.0..1.0))
2518                                        .normalized()
2519                                        * 0.25;
2520                                let end_pos = start_pos
2521                                    + Vec3::unit_z() * body.height()
2522                                    + Vec3::<f32>::zero()
2523                                        .map(|_| rng.gen_range(-1.0..1.0))
2524                                        .normalized();
2525                                Particle::new_directed(
2526                                    Duration::from_secs(1),
2527                                    time,
2528                                    if matches!(buff_kind, BuffKind::Cursed) {
2529                                        ParticleMode::CultistFlame
2530                                    } else {
2531                                        ParticleMode::FlameThrower
2532                                    },
2533                                    start_pos,
2534                                    end_pos,
2535                                )
2536                            },
2537                        );
2538                    },
2539                    BuffKind::PotionSickness => {
2540                        let mut multiplicity = 0;
2541                        // Only show particles for potion sickness at the beginning, after the
2542                        // drinking animation finishes
2543                        if buff_keys.0
2544                            .iter()
2545                            .filter_map(|key| buffs.buffs.get(*key))
2546                            .any(|buff| {
2547                                matches!(buff.elapsed(Time(time)), dur if (1.0..=1.5).contains(&dur.0))
2548                            })
2549                        {
2550                            multiplicity = 1;
2551                        }
2552                        self.particles.resize_with(
2553                            self.particles.len()
2554                                + multiplicity
2555                                    * usize::from(
2556                                        self.scheduler.heartbeats(Duration::from_millis(25)),
2557                                    ),
2558                            || {
2559                                let start_pos = pos
2560                                    + Vec3::unit_z() * body.eye_height(scale.map_or(1.0, |s| s.0));
2561                                let (radius, theta) =
2562                                    (rng.gen_range(0.0f32..1.0).sqrt(), rng.gen_range(0.0..TAU));
2563                                let end_pos = pos
2564                                    + *ori.look_dir()
2565                                    + Vec3::<f32>::new(
2566                                        radius * theta.cos(),
2567                                        radius * theta.sin(),
2568                                        0.0,
2569                                    ) * 0.25;
2570                                Particle::new_directed(
2571                                    Duration::from_secs(1),
2572                                    time,
2573                                    ParticleMode::PotionSickness,
2574                                    start_pos,
2575                                    end_pos,
2576                                )
2577                            },
2578                        );
2579                    },
2580                    BuffKind::Frenzied => {
2581                        self.particles.resize_with(
2582                            self.particles.len()
2583                                + usize::from(self.scheduler.heartbeats(Duration::from_millis(15))),
2584                            || {
2585                                let start_pos = pos
2586                                    + Vec3::new(
2587                                        body.max_radius(),
2588                                        body.max_radius(),
2589                                        body.height() / 2.0,
2590                                    )
2591                                    .map(|d| d * rng.gen_range(-1.0..1.0));
2592                                let end_pos = start_pos
2593                                    + Vec3::unit_z() * body.height()
2594                                    + Vec3::<f32>::zero()
2595                                        .map(|_| rng.gen_range(-1.0..1.0))
2596                                        .normalized();
2597                                Particle::new_directed(
2598                                    Duration::from_secs(1),
2599                                    time,
2600                                    ParticleMode::Enraged,
2601                                    start_pos,
2602                                    end_pos,
2603                                )
2604                            },
2605                        );
2606                    },
2607                    BuffKind::Polymorphed => {
2608                        let mut multiplicity = 0;
2609                        // Only show particles for polymorph at the beginning, after the
2610                        // drinking animation finishes
2611                        if buff_keys.0
2612                            .iter()
2613                            .filter_map(|key| buffs.buffs.get(*key))
2614                            .any(|buff| {
2615                                matches!(buff.elapsed(Time(time)), dur if (0.1..=0.3).contains(&dur.0))
2616                            })
2617                        {
2618                            multiplicity = 1;
2619                        }
2620                        self.particles.resize_with(
2621                            self.particles.len()
2622                                + multiplicity
2623                                    * self.scheduler.heartbeats(Duration::from_millis(3)) as usize,
2624                            || {
2625                                let start_pos = pos
2626                                    + Vec3::unit_z() * body.eye_height(scale.map_or(1.0, |s| s.0))
2627                                        / 2.0;
2628                                let end_pos = start_pos
2629                                    + Vec3::<f32>::zero()
2630                                        .map(|_| rng.gen_range(-1.0..1.0))
2631                                        .normalized()
2632                                        * 5.0;
2633
2634                                Particle::new_directed(
2635                                    Duration::from_secs(2),
2636                                    time,
2637                                    ParticleMode::Explosion,
2638                                    start_pos,
2639                                    end_pos,
2640                                )
2641                            },
2642                        )
2643                    },
2644                    _ => {},
2645                }
2646            }
2647        }
2648    }
2649
2650    fn maintain_block_particles(
2651        &mut self,
2652        scene_data: &SceneData,
2653        terrain: &Terrain<TerrainChunk>,
2654        figure_mgr: &FigureMgr,
2655    ) {
2656        prof_span!("ParticleMgr::maintain_block_particles");
2657        let dt = scene_data.state.ecs().fetch::<DeltaTime>().0;
2658        let time = scene_data.state.get_time();
2659        let player_pos = scene_data
2660            .state
2661            .read_component_copied::<Interpolated>(scene_data.viewpoint_entity)
2662            .map(|i| i.pos)
2663            .unwrap_or_default();
2664        let player_chunk = player_pos.xy().map2(TerrainChunk::RECT_SIZE, |e, sz| {
2665            (e.floor() as i32).div_euclid(sz as i32)
2666        });
2667
2668        struct BlockParticles<'a> {
2669            // The function to select the blocks of interest that we should emit from
2670            blocks: fn(&'a BlocksOfInterest) -> BlockParticleSlice<'a>,
2671            // The range, in chunks, that the particles should be generated in from the player
2672            range: usize,
2673            // The emission rate, per block per second, of the generated particles
2674            rate: f32,
2675            // The number of seconds that each particle should live for
2676            lifetime: f32,
2677            // The visual mode of the generated particle
2678            mode: ParticleMode,
2679            // Condition that must be true
2680            cond: fn(&SceneData) -> bool,
2681        }
2682
2683        enum BlockParticleSlice<'a> {
2684            Positions(&'a [Vec3<i32>]),
2685            PositionsAndDirs(&'a [(Vec3<i32>, Vec3<f32>)]),
2686        }
2687
2688        impl BlockParticleSlice<'_> {
2689            fn len(&self) -> usize {
2690                match self {
2691                    Self::Positions(blocks) => blocks.len(),
2692                    Self::PositionsAndDirs(blocks) => blocks.len(),
2693                }
2694            }
2695        }
2696
2697        let particles: &[BlockParticles] = &[
2698            BlockParticles {
2699                blocks: |boi| BlockParticleSlice::Positions(&boi.leaves),
2700                range: 4,
2701                rate: 0.0125,
2702                lifetime: 30.0,
2703                mode: ParticleMode::Leaf,
2704                cond: |_| true,
2705            },
2706            BlockParticles {
2707                blocks: |boi| BlockParticleSlice::Positions(&boi.drip),
2708                range: 4,
2709                rate: 0.004,
2710                lifetime: 20.0,
2711                mode: ParticleMode::Drip,
2712                cond: |_| true,
2713            },
2714            BlockParticles {
2715                blocks: |boi| BlockParticleSlice::Positions(&boi.fires),
2716                range: 2,
2717                rate: 20.0,
2718                lifetime: 0.25,
2719                mode: ParticleMode::CampfireFire,
2720                cond: |_| true,
2721            },
2722            BlockParticles {
2723                blocks: |boi| BlockParticleSlice::Positions(&boi.fire_bowls),
2724                range: 2,
2725                rate: 20.0,
2726                lifetime: 0.25,
2727                mode: ParticleMode::FireBowl,
2728                cond: |_| true,
2729            },
2730            BlockParticles {
2731                blocks: |boi| BlockParticleSlice::Positions(&boi.fireflies),
2732                range: 6,
2733                rate: 0.004,
2734                lifetime: 40.0,
2735                mode: ParticleMode::Firefly,
2736                cond: |sd| sd.state.get_day_period().is_dark(),
2737            },
2738            BlockParticles {
2739                blocks: |boi| BlockParticleSlice::Positions(&boi.flowers),
2740                range: 5,
2741                rate: 0.002,
2742                lifetime: 40.0,
2743                mode: ParticleMode::Firefly,
2744                cond: |sd| sd.state.get_day_period().is_dark(),
2745            },
2746            BlockParticles {
2747                blocks: |boi| BlockParticleSlice::Positions(&boi.beehives),
2748                range: 3,
2749                rate: 0.5,
2750                lifetime: 30.0,
2751                mode: ParticleMode::Bee,
2752                cond: |sd| sd.state.get_day_period().is_light(),
2753            },
2754            BlockParticles {
2755                blocks: |boi| BlockParticleSlice::Positions(&boi.snow),
2756                range: 4,
2757                rate: 0.025,
2758                lifetime: 15.0,
2759                mode: ParticleMode::Snow,
2760                cond: |_| true,
2761            },
2762            BlockParticles {
2763                blocks: |boi| BlockParticleSlice::PositionsAndDirs(&boi.one_way_walls),
2764                range: 2,
2765                rate: 12.0,
2766                lifetime: 1.5,
2767                mode: ParticleMode::PortalFizz,
2768                cond: |_| true,
2769            },
2770            BlockParticles {
2771                blocks: |boi| BlockParticleSlice::Positions(&boi.spores),
2772                range: 4,
2773                rate: 0.055,
2774                lifetime: 20.0,
2775                mode: ParticleMode::Spore,
2776                cond: |_| true,
2777            },
2778            BlockParticles {
2779                blocks: |boi| BlockParticleSlice::PositionsAndDirs(&boi.waterfall),
2780                range: 2,
2781                rate: 4.0,
2782                lifetime: 5.0,
2783                mode: ParticleMode::WaterFoam,
2784                cond: |_| true,
2785            },
2786            BlockParticles {
2787                blocks: |boi| BlockParticleSlice::Positions(&boi.train_smokes),
2788                range: 2,
2789                rate: 50.0,
2790                lifetime: 8.0,
2791                mode: ParticleMode::TrainSmoke,
2792                cond: |_| true,
2793            },
2794        ];
2795
2796        let ecs = scene_data.state.ecs();
2797        let mut rng = thread_rng();
2798        // Hard cap for performance reasons; Assuming that 25% of a chunk is covered in
2799        // lava or 32*32*0.25 = 256 TODO: Make this a setting?
2800        let cap = 512;
2801        for particles in particles.iter() {
2802            if !(particles.cond)(scene_data) {
2803                continue;
2804            }
2805
2806            for offset in Spiral2d::new().take((particles.range * 2 + 1).pow(2)) {
2807                let chunk_pos = player_chunk + offset;
2808
2809                terrain.get(chunk_pos).map(|chunk_data| {
2810                    let blocks = (particles.blocks)(&chunk_data.blocks_of_interest);
2811
2812                    let avg_particles = dt * (blocks.len() as f32 * particles.rate).min(cap as f32);
2813                    let particle_count = avg_particles.trunc() as usize
2814                        + (rng.gen::<f32>() < avg_particles.fract()) as usize;
2815
2816                    self.particles
2817                        .resize_with(self.particles.len() + particle_count, || {
2818                            match blocks {
2819                                BlockParticleSlice::Positions(blocks) => {
2820                                    // Can't fail, resize only occurs if blocks > 0
2821                                    let block_pos = Vec3::from(
2822                                        chunk_pos * TerrainChunk::RECT_SIZE.map(|e| e as i32),
2823                                    ) + blocks.choose(&mut rng).copied().unwrap();
2824                                    Particle::new(
2825                                        Duration::from_secs_f32(particles.lifetime),
2826                                        time,
2827                                        particles.mode,
2828                                        block_pos.map(|e: i32| e as f32 + rng.gen::<f32>()),
2829                                    )
2830                                },
2831                                BlockParticleSlice::PositionsAndDirs(blocks) => {
2832                                    // Can't fail, resize only occurs if blocks > 0
2833                                    let (block_offset, particle_dir) =
2834                                        blocks.choose(&mut rng).copied().unwrap();
2835                                    let block_pos = Vec3::from(
2836                                        chunk_pos * TerrainChunk::RECT_SIZE.map(|e| e as i32),
2837                                    ) + block_offset;
2838                                    let particle_pos =
2839                                        block_pos.map(|e: i32| e as f32 + rng.gen::<f32>());
2840                                    Particle::new_directed(
2841                                        Duration::from_secs_f32(particles.lifetime),
2842                                        time,
2843                                        particles.mode,
2844                                        particle_pos,
2845                                        particle_pos + particle_dir,
2846                                    )
2847                                },
2848                            }
2849                        })
2850                });
2851            }
2852
2853            for (entity, body, interpolated, collider) in (
2854                &ecs.entities(),
2855                &ecs.read_storage::<comp::Body>(),
2856                &ecs.read_storage::<crate::ecs::comp::Interpolated>(),
2857                ecs.read_storage::<comp::Collider>().maybe(),
2858            )
2859                .join()
2860            {
2861                if let Some((blocks_of_interest, offset)) =
2862                    figure_mgr.get_blocks_of_interest(entity, body, collider)
2863                {
2864                    let mat = Mat4::from(interpolated.ori.to_quat())
2865                        .translated_3d(interpolated.pos)
2866                        * Mat4::translation_3d(offset);
2867
2868                    let blocks = (particles.blocks)(blocks_of_interest);
2869
2870                    let avg_particles = dt * blocks.len() as f32 * particles.rate;
2871                    let particle_count = avg_particles.trunc() as usize
2872                        + (rng.gen::<f32>() < avg_particles.fract()) as usize;
2873
2874                    self.particles
2875                        .resize_with(self.particles.len() + particle_count, || {
2876                            match blocks {
2877                                BlockParticleSlice::Positions(blocks) => {
2878                                    let rel_pos = blocks
2879                                        .choose(&mut rng)
2880                                        .copied()
2881                                        // Can't fail, resize only occurs if blocks > 0
2882                                        .unwrap()
2883                                        .map(|e: i32| e as f32 + rng.gen::<f32>());
2884                                    let wpos = mat.mul_point(rel_pos);
2885
2886                                    Particle::new(
2887                                        Duration::from_secs_f32(particles.lifetime),
2888                                        time,
2889                                        particles.mode,
2890                                        wpos,
2891                                    )
2892                                },
2893                                BlockParticleSlice::PositionsAndDirs(blocks) => {
2894                                    // Can't fail, resize only occurs if blocks > 0
2895                                    let (block_offset, particle_dir) =
2896                                        blocks.choose(&mut rng).copied().unwrap();
2897                                    let particle_pos =
2898                                        block_offset.map(|e: i32| e as f32 + rng.gen::<f32>());
2899                                    let wpos = mat.mul_point(particle_pos);
2900                                    Particle::new_directed(
2901                                        Duration::from_secs_f32(particles.lifetime),
2902                                        time,
2903                                        particles.mode,
2904                                        wpos,
2905                                        wpos + mat.mul_direction(particle_dir),
2906                                    )
2907                                },
2908                            }
2909                        })
2910                }
2911            }
2912        }
2913        // smoke is more complex as it comes with varying rate and color
2914        {
2915            struct SmokeProperties {
2916                position: Vec3<i32>,
2917                strength: f32,
2918                dry_chance: f32,
2919            }
2920
2921            let range = 8_usize;
2922            let rate = 3.0 / 128.0;
2923            let lifetime = 40.0;
2924            let time_of_day = scene_data
2925                .state
2926                .get_time_of_day()
2927                .rem_euclid(24.0 * 60.0 * 60.0) as f32;
2928
2929            let smokers = Spiral2d::new()
2930                .take((range * 2 + 1).pow(2))
2931                .flat_map(|offset| {
2932                    let chunk_pos = player_chunk + offset;
2933                    let block_pos =
2934                        Vec3::<i32>::from(chunk_pos * TerrainChunk::RECT_SIZE.map(|e| e as i32));
2935                    terrain.get(chunk_pos).into_iter().flat_map(move |chunk| {
2936                        chunk.blocks_of_interest.smokers.iter().map(move |smoker| {
2937                            (
2938                                block_pos.as_::<f32>() + smoker.position.as_(),
2939                                smoker.kind,
2940                                chunk.blocks_of_interest.temperature,
2941                                chunk.blocks_of_interest.humidity,
2942                            )
2943                        })
2944                    })
2945                })
2946                .chain(
2947                    (
2948                        &ecs.entities(),
2949                        &ecs.read_storage::<comp::Body>(),
2950                        &ecs.read_storage::<crate::ecs::comp::Interpolated>(),
2951                        ecs.read_storage::<comp::Collider>().maybe(),
2952                    )
2953                        .join()
2954                        .flat_map(|(entity, body, interpolated, collider)| {
2955                            figure_mgr
2956                                .get_blocks_of_interest(entity, body, collider)
2957                                .into_iter()
2958                                .flat_map(|(boi, offset)| {
2959                                    let mat = Mat4::from(interpolated.ori.to_quat())
2960                                        .translated_3d(interpolated.pos)
2961                                        * Mat4::translation_3d(offset);
2962                                    boi.smokers.iter().map(move |smoker| {
2963                                        (
2964                                            mat.mul_point(smoker.position.as_::<f32>() + 0.5),
2965                                            smoker.kind,
2966                                            0.0, // TODO: Actual temperature & humidity
2967                                            0.5,
2968                                        )
2969                                    })
2970                                })
2971                        })
2972                        .collect::<Vec<_>>(),
2973                );
2974
2975            let mut smoke_properties: Vec<SmokeProperties> = Vec::new();
2976            let mut sum = 0.0_f32;
2977            for (pos, kind, temperature, humidity) in smokers {
2978                let (strength, dry_chance) = {
2979                    match kind {
2980                        FireplaceType::House => {
2981                            let prop = crate::scene::smoke_cycle::smoke_at_time(
2982                                pos.round().as_(),
2983                                temperature,
2984                                time_of_day,
2985                            );
2986                            (
2987                                prop.0,
2988                                if prop.1 {
2989                                    // fire started, dark smoke
2990                                    0.8 - humidity
2991                                } else {
2992                                    // fire continues, light smoke
2993                                    1.2 - humidity
2994                                },
2995                            )
2996                        },
2997                        FireplaceType::Workshop => (128.0, 1.0),
2998                    }
2999                };
3000                sum += strength;
3001                smoke_properties.push(SmokeProperties {
3002                    position: pos.round().as_(),
3003                    strength,
3004                    dry_chance,
3005                });
3006            }
3007            let avg_particles = dt * sum * rate;
3008
3009            let particle_count = avg_particles.trunc() as usize
3010                + (rng.gen::<f32>() < avg_particles.fract()) as usize;
3011            let chosen =
3012                smoke_properties
3013                    .choose_multiple_weighted(&mut rng, particle_count, |smoker| smoker.strength);
3014            if let Ok(chosen) = chosen {
3015                self.particles.extend(chosen.map(|smoker| {
3016                    Particle::new(
3017                        Duration::from_secs_f32(lifetime),
3018                        time,
3019                        if rng.gen::<f32>() > smoker.dry_chance {
3020                            ParticleMode::BlackSmoke
3021                        } else {
3022                            ParticleMode::CampfireSmoke
3023                        },
3024                        smoker.position.map(|e: i32| e as f32 + rng.gen::<f32>()),
3025                    )
3026                }));
3027            }
3028        }
3029    }
3030
3031    fn maintain_shockwave_particles(&mut self, scene_data: &SceneData) {
3032        let state = scene_data.state;
3033        let ecs = state.ecs();
3034        let time = state.get_time();
3035        let dt = scene_data.state.ecs().fetch::<DeltaTime>().0;
3036        let terrain = scene_data.state.ecs().fetch::<TerrainGrid>();
3037
3038        for (_entity, interp, pos, ori, shockwave) in (
3039            &ecs.entities(),
3040            ecs.read_storage::<Interpolated>().maybe(),
3041            &ecs.read_storage::<Pos>(),
3042            &ecs.read_storage::<Ori>(),
3043            &ecs.read_storage::<Shockwave>(),
3044        )
3045            .join()
3046        {
3047            let pos = interp.map_or(pos.0, |i| i.pos);
3048            let ori = interp.map_or(*ori, |i| i.ori);
3049
3050            let elapsed = time - shockwave.creation.unwrap_or(time);
3051            let speed = shockwave.properties.speed;
3052
3053            let percent = elapsed as f32 / shockwave.properties.duration.as_secs_f32();
3054
3055            let distance = speed * elapsed as f32;
3056
3057            let radians = shockwave.properties.angle.to_radians();
3058
3059            let ori_vec = ori.look_vec();
3060            let theta = ori_vec.y.atan2(ori_vec.x) - radians / 2.0;
3061            let dtheta = radians / distance;
3062
3063            // Number of particles derived from arc length (for new particles at least, old
3064            // can be converted later)
3065            let arc_length = distance * radians;
3066
3067            use shockwave::FrontendSpecifier;
3068            match shockwave.properties.specifier {
3069                FrontendSpecifier::Ground => {
3070                    let heartbeats = self.scheduler.heartbeats(Duration::from_millis(2));
3071                    for heartbeat in 0..heartbeats {
3072                        // 1 / 3 the size of terrain voxel
3073                        let scale = 1.0 / 3.0;
3074
3075                        let scaled_speed = speed * scale;
3076
3077                        let sub_tick_interpolation = scaled_speed * 1000.0 * heartbeat as f32;
3078
3079                        let distance = speed * (elapsed as f32 - sub_tick_interpolation);
3080
3081                        let particle_count_factor = radians / (3.0 * scale);
3082                        let new_particle_count = distance * particle_count_factor;
3083                        self.particles.reserve(new_particle_count as usize);
3084
3085                        for d in 0..(new_particle_count as i32) {
3086                            let arc_position = theta + dtheta * d as f32 / particle_count_factor;
3087
3088                            let position = pos
3089                                + distance * Vec3::new(arc_position.cos(), arc_position.sin(), 0.0);
3090
3091                            // Arbitrary number chosen that is large enough to be able to accurately
3092                            // place particles most of the time, but also not too big as to make ray
3093                            // be too large (for performance reasons)
3094                            let half_ray_length = 10.0;
3095                            let mut last_air = false;
3096                            // TODO: Optimize ray to only be cast at most once per block per tick if
3097                            // it becomes an issue.
3098                            // From imbris:
3099                            //      each ray is ~2 us
3100                            //      at 30 FPS, it peaked at 113 rays in a tick
3101                            //      total time was 240 us (although potentially half that is
3102                            //          overhead from the profiling of each ray)
3103                            let _ = terrain
3104                                .ray(
3105                                    position + Vec3::unit_z() * half_ray_length,
3106                                    position - Vec3::unit_z() * half_ray_length,
3107                                )
3108                                .for_each(|block: &Block, pos: Vec3<i32>| {
3109                                    if block.is_solid() && block.get_sprite().is_none() {
3110                                        if last_air {
3111                                            let position = position.xy().with_z(pos.z as f32 + 1.0);
3112
3113                                            let position_snapped =
3114                                                ((position / scale).floor() + 0.5) * scale;
3115
3116                                            self.particles.push(Particle::new(
3117                                                Duration::from_millis(250),
3118                                                time,
3119                                                ParticleMode::GroundShockwave,
3120                                                position_snapped,
3121                                            ));
3122                                            last_air = false;
3123                                        }
3124                                    } else {
3125                                        last_air = true;
3126                                    }
3127                                })
3128                                .cast();
3129                        }
3130                    }
3131                },
3132                FrontendSpecifier::Fire => {
3133                    let heartbeats = self.scheduler.heartbeats(Duration::from_millis(2));
3134                    for _ in 0..heartbeats {
3135                        for d in 0..3 * distance as i32 {
3136                            let arc_position = theta + dtheta * d as f32 / 3.0;
3137
3138                            let position = pos
3139                                + distance * Vec3::new(arc_position.cos(), arc_position.sin(), 0.0);
3140
3141                            self.particles.push(Particle::new(
3142                                Duration::from_secs_f32((distance + 10.0) / 50.0),
3143                                time,
3144                                ParticleMode::FireShockwave,
3145                                position,
3146                            ));
3147                        }
3148                    }
3149                },
3150                FrontendSpecifier::FireLow => {
3151                    let heartbeats = self.scheduler.heartbeats(Duration::from_millis(2));
3152                    for heartbeat in 0..heartbeats {
3153                        // 1 / 3 the size of terrain voxel
3154                        let scale = 1.0 / 3.0;
3155
3156                        let scaled_speed = speed * scale;
3157
3158                        let sub_tick_interpolation = scaled_speed * 1000.0 * heartbeat as f32;
3159
3160                        let distance = speed * (elapsed as f32 - sub_tick_interpolation);
3161
3162                        let particle_count_factor = radians / (3.0 * scale);
3163                        let new_particle_count = distance * particle_count_factor;
3164                        self.particles.reserve(new_particle_count as usize);
3165
3166                        for d in 0..(new_particle_count as i32) {
3167                            let arc_position = theta + dtheta * d as f32 / particle_count_factor;
3168
3169                            let position = pos
3170                                + distance * Vec3::new(arc_position.cos(), arc_position.sin(), 0.0);
3171
3172                            // Arbitrary number chosen that is large enough to be able to accurately
3173                            // place particles most of the time, but also not too big as to make ray
3174                            // be too large (for performance reasons)
3175                            let half_ray_length = 10.0;
3176                            let mut last_air = false;
3177                            // TODO: Optimize ray to only be cast at most once per block per tick if
3178                            // it becomes an issue.
3179                            // From imbris:
3180                            //      each ray is ~2 us
3181                            //      at 30 FPS, it peaked at 113 rays in a tick
3182                            //      total time was 240 us (although potentially half that is
3183                            //          overhead from the profiling of each ray)
3184                            let _ = terrain
3185                                .ray(
3186                                    position + Vec3::unit_z() * half_ray_length,
3187                                    position - Vec3::unit_z() * half_ray_length,
3188                                )
3189                                .for_each(|block: &Block, pos: Vec3<i32>| {
3190                                    if block.is_solid() && block.get_sprite().is_none() {
3191                                        if last_air {
3192                                            let position = position.xy().with_z(pos.z as f32 + 1.0);
3193
3194                                            let position_snapped =
3195                                                ((position / scale).floor() + 0.5) * scale;
3196
3197                                            self.particles.push(Particle::new(
3198                                                Duration::from_millis(250),
3199                                                time,
3200                                                ParticleMode::FireLowShockwave,
3201                                                position_snapped,
3202                                            ));
3203                                            last_air = false;
3204                                        }
3205                                    } else {
3206                                        last_air = true;
3207                                    }
3208                                })
3209                                .cast();
3210                        }
3211                    }
3212                },
3213                FrontendSpecifier::Water => {
3214                    // 1 particle per unit length of arc
3215                    let particles_per_length = arc_length as usize;
3216                    let dtheta = radians / particles_per_length as f32;
3217                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
3218                    // lower duration = more particles
3219                    let heartbeats = self
3220                        .scheduler
3221                        .heartbeats(Duration::from_secs_f32(1.0 / speed));
3222
3223                    // Reserves capacity for new particles
3224                    let new_particle_count = particles_per_length * heartbeats as usize;
3225                    self.particles.reserve(new_particle_count);
3226
3227                    for i in 0..particles_per_length {
3228                        let angle = dtheta * i as f32;
3229                        let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
3230                        for j in 0..heartbeats {
3231                            // Sub tick dt
3232                            let dt = (j as f32 / heartbeats as f32) * dt;
3233                            let distance = distance + speed * dt;
3234                            let pos1 = pos + distance * direction - Vec3::unit_z();
3235                            let pos2 = pos1 + (Vec3::unit_z() + direction) * 3.0;
3236                            let time = time + dt as f64;
3237
3238                            self.particles.push(Particle::new_directed(
3239                                Duration::from_secs_f32(0.5),
3240                                time,
3241                                ParticleMode::Water,
3242                                pos1,
3243                                pos2,
3244                            ));
3245                        }
3246                    }
3247                },
3248                FrontendSpecifier::Lightning => {
3249                    // 1 particle per unit length of arc
3250                    let particles_per_length = arc_length as usize;
3251                    let dtheta = radians / particles_per_length as f32;
3252                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
3253                    // lower duration = more particles
3254                    let heartbeats = self
3255                        .scheduler
3256                        .heartbeats(Duration::from_secs_f32(1.0 / speed));
3257
3258                    // Reserves capacity for new particles
3259                    let new_particle_count = particles_per_length * heartbeats as usize;
3260                    self.particles.reserve(new_particle_count);
3261
3262                    for i in 0..particles_per_length {
3263                        let angle = dtheta * i as f32;
3264                        let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
3265                        for j in 0..heartbeats {
3266                            // Sub tick dt
3267                            let dt = (j as f32 / heartbeats as f32) * dt;
3268                            let distance = distance + speed * dt;
3269                            let pos1 = pos + distance * direction - Vec3::unit_z();
3270                            let pos2 = pos1 + (Vec3::unit_z() + direction) * 3.0;
3271                            let time = time + dt as f64;
3272
3273                            self.particles.push(Particle::new_directed(
3274                                Duration::from_secs_f32(0.5),
3275                                time,
3276                                ParticleMode::Lightning,
3277                                pos1,
3278                                pos2,
3279                            ));
3280                        }
3281                    }
3282                },
3283                FrontendSpecifier::Steam => {
3284                    // 1 particle per unit length of arc
3285                    let particles_per_length = arc_length as usize;
3286                    let dtheta = radians / particles_per_length as f32;
3287                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
3288                    // lower duration = more particles
3289                    let heartbeats = self
3290                        .scheduler
3291                        .heartbeats(Duration::from_secs_f32(1.0 / speed));
3292
3293                    // Reserves capacity for new particles
3294                    let new_particle_count = particles_per_length * heartbeats as usize;
3295                    self.particles.reserve(new_particle_count);
3296
3297                    for i in 0..particles_per_length {
3298                        let angle = dtheta * i as f32;
3299                        let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
3300                        for j in 0..heartbeats {
3301                            // Sub tick dt
3302                            let dt = (j as f32 / heartbeats as f32) * dt;
3303                            let distance = distance + speed * dt;
3304                            let pos1 = pos + distance * direction - Vec3::unit_z();
3305                            let pos2 = pos1 + (Vec3::unit_z() + direction) * 3.0;
3306                            let time = time + dt as f64;
3307
3308                            self.particles.push(Particle::new_directed(
3309                                Duration::from_secs_f32(0.5),
3310                                time,
3311                                ParticleMode::Steam,
3312                                pos1,
3313                                pos2,
3314                            ));
3315                        }
3316                    }
3317                },
3318                FrontendSpecifier::Poison => {
3319                    // 1 particle per unit length of arc
3320                    let particles_per_length = arc_length as usize;
3321                    let dtheta = radians / particles_per_length as f32;
3322                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
3323                    // lower duration = more particles
3324                    let heartbeats = self
3325                        .scheduler
3326                        .heartbeats(Duration::from_secs_f32(1.0 / speed));
3327
3328                    // Reserves capacity for new particles
3329                    let new_particle_count = particles_per_length * heartbeats as usize;
3330                    self.particles.reserve(new_particle_count);
3331
3332                    for i in 0..particles_per_length {
3333                        let angle = theta + dtheta * i as f32;
3334                        let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
3335                        for j in 0..heartbeats {
3336                            // Sub tick dt
3337                            let dt = (j as f32 / heartbeats as f32) * dt;
3338                            let distance = distance + speed * dt;
3339                            let pos1 = pos + distance * direction - Vec3::unit_z();
3340                            let pos2 = pos1 + (Vec3::unit_z() + direction) * 3.0;
3341                            let time = time + dt as f64;
3342
3343                            self.particles.push(Particle::new_directed(
3344                                Duration::from_secs_f32(0.5),
3345                                time,
3346                                ParticleMode::Poison,
3347                                pos1,
3348                                pos2,
3349                            ));
3350                        }
3351                    }
3352                },
3353                FrontendSpecifier::AcidCloud => {
3354                    let particles_per_height = 5;
3355                    // 1 particle per unit length of arc
3356                    let particles_per_length = arc_length as usize;
3357                    let dtheta = radians / particles_per_length as f32;
3358                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
3359                    // lower duration = more particles
3360                    let heartbeats = self
3361                        .scheduler
3362                        .heartbeats(Duration::from_secs_f32(1.0 / speed));
3363
3364                    // Reserves capacity for new particles
3365                    let new_particle_count =
3366                        particles_per_length * heartbeats as usize * particles_per_height;
3367                    self.particles.reserve(new_particle_count);
3368
3369                    for i in 0..particles_per_height {
3370                        let height = (i as f32 / (particles_per_height as f32 - 1.0)) * 4.0;
3371                        for j in 0..particles_per_length {
3372                            let angle = theta + dtheta * j as f32;
3373                            let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
3374                            for k in 0..heartbeats {
3375                                // Sub tick dt
3376                                let dt = (k as f32 / heartbeats as f32) * dt;
3377                                let distance = distance + speed * dt;
3378                                let pos1 = pos + distance * direction - Vec3::unit_z()
3379                                    + Vec3::unit_z() * height;
3380                                let pos2 = pos1 + direction;
3381                                let time = time + dt as f64;
3382
3383                                self.particles.push(Particle::new_directed(
3384                                    Duration::from_secs_f32(0.5),
3385                                    time,
3386                                    ParticleMode::Poison,
3387                                    pos1,
3388                                    pos2,
3389                                ));
3390                            }
3391                        }
3392                    }
3393                },
3394                FrontendSpecifier::Ink => {
3395                    // 1 particle per unit length of arc
3396                    let particles_per_length = arc_length as usize;
3397                    let dtheta = radians / particles_per_length as f32;
3398                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
3399                    // lower duration = more particles
3400                    let heartbeats = self
3401                        .scheduler
3402                        .heartbeats(Duration::from_secs_f32(1.0 / speed));
3403
3404                    // Reserves capacity for new particles
3405                    let new_particle_count = particles_per_length * heartbeats as usize;
3406                    self.particles.reserve(new_particle_count);
3407
3408                    for i in 0..particles_per_length {
3409                        let angle = theta + dtheta * i as f32;
3410                        let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
3411                        for j in 0..heartbeats {
3412                            // Sub tick dt
3413                            let dt = (j as f32 / heartbeats as f32) * dt;
3414                            let distance = distance + speed * dt;
3415                            let pos1 = pos + distance * direction - Vec3::unit_z();
3416                            let pos2 = pos1 + (Vec3::unit_z() + direction) * 3.0;
3417                            let time = time + dt as f64;
3418
3419                            self.particles.push(Particle::new_directed(
3420                                Duration::from_secs_f32(0.5),
3421                                time,
3422                                ParticleMode::Ink,
3423                                pos1,
3424                                pos2,
3425                            ));
3426                        }
3427                    }
3428                },
3429                FrontendSpecifier::IceSpikes | FrontendSpecifier::Ice => {
3430                    // 1 / 3 the size of terrain voxel
3431                    let scale = 1.0 / 3.0;
3432                    let scaled_distance = distance / scale;
3433                    let scaled_speed = speed / scale;
3434
3435                    // 1 particle per scaled unit length of arc
3436                    let particles_per_length = (0.25 * arc_length / scale) as usize;
3437                    let dtheta = radians / particles_per_length as f32;
3438                    // Scales number of desired heartbeats from speed - thicker arc = higher speed =
3439                    // lower duration = more particles
3440                    let heartbeats = self
3441                        .scheduler
3442                        .heartbeats(Duration::from_secs_f32(3.0 / scaled_speed));
3443
3444                    // Reserves capacity for new particles
3445                    let new_particle_count = particles_per_length * heartbeats as usize;
3446                    self.particles.reserve(new_particle_count);
3447                    // higher wave when wave doesn't require ground
3448                    let wave = if matches!(shockwave.properties.dodgeable, Dodgeable::Jump) {
3449                        0.5
3450                    } else {
3451                        8.0
3452                    };
3453                    // Used to make taller the further out spikes are
3454                    let height_scale = wave + 1.5 * percent;
3455                    for i in 0..particles_per_length {
3456                        let angle = theta + dtheta * i as f32;
3457                        let direction = Vec3::new(angle.cos(), angle.sin(), 0.0);
3458                        for j in 0..heartbeats {
3459                            // Sub tick dt
3460                            let dt = (j as f32 / heartbeats as f32) * dt;
3461                            let scaled_distance = scaled_distance + scaled_speed * dt;
3462                            let mut pos1 = pos + (scaled_distance * direction).floor() * scale;
3463                            let time = time + dt as f64;
3464
3465                            // Arbitrary number chosen that is large enough to be able to accurately
3466                            // place particles most of the time, but also not too big as to make ray
3467                            // be too large (for performance reasons)
3468                            let half_ray_length = 10.0;
3469                            let mut last_air = false;
3470                            // TODO: Optimize ray to only be cast at most once per block per tick if
3471                            // it becomes an issue.
3472                            // From imbris:
3473                            //      each ray is ~2 us
3474                            //      at 30 FPS, it peaked at 113 rays in a tick
3475                            //      total time was 240 us (although potentially half that is
3476                            //          overhead from the profiling of each ray)
3477                            let _ = terrain
3478                                .ray(
3479                                    pos1 + Vec3::unit_z() * half_ray_length,
3480                                    pos1 - Vec3::unit_z() * half_ray_length,
3481                                )
3482                                .for_each(|block: &Block, pos: Vec3<i32>| {
3483                                    if block.is_solid() && block.get_sprite().is_none() {
3484                                        if last_air {
3485                                            pos1 = pos1.xy().with_z(pos.z as f32 + 1.0);
3486                                            last_air = false;
3487                                        }
3488                                    } else {
3489                                        last_air = true;
3490                                    }
3491                                })
3492                                .cast();
3493
3494                            let get_positions = |a| {
3495                                let pos1 = match a {
3496                                    2 => pos1 + Vec3::unit_x() * scale,
3497                                    3 => pos1 - Vec3::unit_x() * scale,
3498                                    4 => pos1 + Vec3::unit_y() * scale,
3499                                    5 => pos1 - Vec3::unit_y() * scale,
3500                                    _ => pos1,
3501                                };
3502                                let pos2 = if a == 1 {
3503                                    pos1 + Vec3::unit_z() * 5.0 * height_scale
3504                                } else {
3505                                    pos1 + Vec3::unit_z() * 1.0 * height_scale
3506                                };
3507                                (pos1, pos2)
3508                            };
3509
3510                            for a in 1..=5 {
3511                                let (pos1, pos2) = get_positions(a);
3512                                self.particles.push(Particle::new_directed(
3513                                    Duration::from_secs_f32(0.5),
3514                                    time,
3515                                    ParticleMode::IceSpikes,
3516                                    pos1,
3517                                    pos2,
3518                                ));
3519                            }
3520                        }
3521                    }
3522                },
3523            }
3524        }
3525    }
3526
3527    fn upload_particles(&mut self, renderer: &mut Renderer) {
3528        prof_span!("ParticleMgr::upload_particles");
3529        let all_cpu_instances = self
3530            .particles
3531            .iter()
3532            .map(|p| p.instance)
3533            .collect::<Vec<ParticleInstance>>();
3534
3535        // TODO: optimise buffer writes
3536        let gpu_instances = renderer.create_instances(&all_cpu_instances);
3537
3538        self.instances = gpu_instances;
3539    }
3540
3541    pub fn render<'a>(&'a self, drawer: &mut ParticleDrawer<'_, 'a>, scene_data: &SceneData) {
3542        prof_span!("ParticleMgr::render");
3543        if scene_data.particles_enabled {
3544            let model = &self
3545                .model_cache
3546                .get(DEFAULT_MODEL_KEY)
3547                .expect("Expected particle model in cache");
3548
3549            drawer.draw(model, &self.instances);
3550        }
3551    }
3552
3553    pub fn particle_count(&self) -> usize { self.instances.count() }
3554
3555    pub fn particle_count_visible(&self) -> usize { self.instances.count() }
3556}
3557
3558fn default_instances(renderer: &mut Renderer) -> Instances<ParticleInstance> {
3559    let empty_vec = Vec::new();
3560
3561    renderer.create_instances(&empty_vec)
3562}
3563
3564const DEFAULT_MODEL_KEY: &str = "voxygen.voxel.particle";
3565
3566fn default_cache(renderer: &mut Renderer) -> HashMap<&'static str, Model<ParticleVertex>> {
3567    let mut model_cache = HashMap::new();
3568
3569    model_cache.entry(DEFAULT_MODEL_KEY).or_insert_with(|| {
3570        let vox = DotVoxAsset::load_expect(DEFAULT_MODEL_KEY);
3571
3572        // NOTE: If we add texturing we may eventually try to share it among all
3573        // particles in a single atlas.
3574        let max_texture_size = renderer.max_texture_size();
3575        let max_size = Vec2::from(u16::try_from(max_texture_size).unwrap_or(u16::MAX));
3576        let mut greedy = GreedyMesh::new(max_size, crate::mesh::greedy::general_config());
3577
3578        let segment = Segment::from_vox_model_index(&vox.read().0, 0);
3579        let segment_size = segment.size();
3580        let mut mesh = generate_mesh_base_vol_particle(segment, &mut greedy).0;
3581        // Center particle vertices around origin
3582        for vert in mesh.vertices_mut() {
3583            vert.pos[0] -= segment_size.x as f32 / 2.0;
3584            vert.pos[1] -= segment_size.y as f32 / 2.0;
3585            vert.pos[2] -= segment_size.z as f32 / 2.0;
3586        }
3587
3588        // NOTE: Ignoring coloring / lighting for now.
3589        drop(greedy);
3590
3591        renderer
3592            .create_model(&mesh)
3593            .expect("Failed to create particle model")
3594    });
3595
3596    model_cache
3597}
3598
3599/// Accumulates heartbeats to be consumed on the next tick.
3600struct HeartbeatScheduler {
3601    /// Duration = Heartbeat Frequency/Intervals
3602    /// f64 = Last update time
3603    /// u8 = number of heartbeats since last update
3604    /// - if it's more frequent then tick rate, it could be 1 or more.
3605    /// - if it's less frequent then tick rate, it could be 1 or 0.
3606    /// - if it's equal to the tick rate, it could be between 2 and 0, due to
3607    ///   delta time variance etc.
3608    timers: HashMap<Duration, (f64, u8)>,
3609
3610    last_known_time: f64,
3611}
3612
3613impl HeartbeatScheduler {
3614    pub fn new() -> Self {
3615        HeartbeatScheduler {
3616            timers: HashMap::new(),
3617            last_known_time: 0.0,
3618        }
3619    }
3620
3621    /// updates the last elapsed times and elapsed counts
3622    /// this should be called once, and only once per tick.
3623    pub fn maintain(&mut self, now: f64) {
3624        prof_span!("HeartbeatScheduler::maintain");
3625        self.last_known_time = now;
3626
3627        for (frequency, (last_update, heartbeats)) in self.timers.iter_mut() {
3628            // the number of frequency cycles that have occurred.
3629            let total_heartbeats = (now - *last_update) / frequency.as_secs_f64();
3630
3631            // exclude partial frequency cycles
3632            let full_heartbeats = total_heartbeats.floor();
3633
3634            *heartbeats = full_heartbeats as u8;
3635
3636            // the remaining partial frequency cycle, as a decimal.
3637            let partial_heartbeat = total_heartbeats - full_heartbeats;
3638
3639            // the remaining partial frequency cycle, as a unit of time(f64).
3640            let partial_heartbeat_as_time = frequency.mul_f64(partial_heartbeat).as_secs_f64();
3641
3642            // now minus the left over heart beat count precision as seconds,
3643            // Note: we want to preserve incomplete heartbeats, and roll them
3644            // over into the next update.
3645            *last_update = now - partial_heartbeat_as_time;
3646        }
3647    }
3648
3649    /// returns the number of times this duration has elapsed since the last
3650    /// tick:
3651    ///   - if it's more frequent then tick rate, it could be 1 or more.
3652    ///   - if it's less frequent then tick rate, it could be 1 or 0.
3653    ///   - if it's equal to the tick rate, it could be between 2 and 0, due to
3654    ///     delta time variance.
3655    pub fn heartbeats(&mut self, frequency: Duration) -> u8 {
3656        prof_span!("HeartbeatScheduler::heartbeats");
3657        let last_known_time = self.last_known_time;
3658
3659        self.timers
3660            .entry(frequency)
3661            .or_insert_with(|| (last_known_time, 0))
3662            .1
3663    }
3664
3665    pub fn clear(&mut self) { self.timers.clear() }
3666}
3667
3668#[derive(Clone, Copy)]
3669struct Particle {
3670    alive_until: f64, // created_at + lifespan
3671    instance: ParticleInstance,
3672}
3673
3674impl Particle {
3675    fn new(lifespan: Duration, time: f64, mode: ParticleMode, pos: Vec3<f32>) -> Self {
3676        Particle {
3677            alive_until: time + lifespan.as_secs_f64(),
3678            instance: ParticleInstance::new(time, lifespan.as_secs_f32(), mode, pos),
3679        }
3680    }
3681
3682    fn new_directed(
3683        lifespan: Duration,
3684        time: f64,
3685        mode: ParticleMode,
3686        pos1: Vec3<f32>,
3687        pos2: Vec3<f32>,
3688    ) -> Self {
3689        Particle {
3690            alive_until: time + lifespan.as_secs_f64(),
3691            instance: ParticleInstance::new_directed(
3692                time,
3693                lifespan.as_secs_f32(),
3694                mode,
3695                pos1,
3696                pos2,
3697            ),
3698        }
3699    }
3700
3701    fn new_directed_with_collision(
3702        lifespan: Duration,
3703        time: f64,
3704        mode: ParticleMode,
3705        pos1: Vec3<f32>,
3706        pos2: Vec3<f32>,
3707        distance: impl Fn(Vec3<f32>, Vec3<f32>) -> f32,
3708    ) -> Self {
3709        let dir = pos2 - pos1;
3710        let end_distance = pos1.distance(pos2);
3711        let (end_pos, lifespawn) = if end_distance > 0.1 {
3712            let ratio = distance(pos1, pos2) / end_distance;
3713            (pos1 + ratio * dir, lifespan.mul_f32(ratio))
3714        } else {
3715            (pos2, lifespan)
3716        };
3717
3718        Self::new_directed(lifespawn, time, mode, pos1, end_pos)
3719    }
3720}