Skip to main content

veloren_voxygen/scene/
particle.rs

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