veloren_voxygen/scene/
particle.rs

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