Skip to main content

proof_engine/vfx/
mod.rs

1//! Visual effects system: decals, trails, impact splats, ribbon renderers,
2//! screen-space effects, procedural destruction visuals, particle emitters,
3//! effect presets and force fields.
4
5pub mod emitter;
6pub mod effects;
7pub mod forces;
8
9pub use emitter::{
10    Emitter, EmitterConfig, EmitterPool, EmitterBuilder, EmitterShape,
11    SpawnMode, SpawnCurve, VelocityMode, ColorOverLifetime, SizeOverLifetime,
12    LodController, LodLevel, EmitterTransformAnim, TransformKeyframe,
13    Particle, ParticleTag, lcg_f32, lcg_range, lcg_next,
14};
15pub use effects::{
16    EffectPreset, EffectRegistry,
17    ExplosionEffect, FireEffect, SmokeEffect, SparksEffect, BloodSplatterEffect,
18    MagicAuraEffect, MagicElement, PortalSwirlEffect, LightningArcEffect,
19    WaterSplashEffect, DustCloudEffect,
20};
21pub use forces::{
22    ForceField, ForceFieldId, ForceFieldKind, ForceFieldWorld, ForceComposite,
23    ForceBlendMode, ForcePresets, FalloffMode, TagMask,
24    GravityWell, VortexField, TurbulenceField, WindZone,
25    AttractorRepulsor, AttractorMode, DragField, BuoyancyField,
26    ForceDebugSample,
27};
28
29use glam::{Vec2, Vec3, Vec4, Mat4, Quat};
30use std::collections::HashMap;
31
32// ─── Decal ────────────────────────────────────────────────────────────────────
33
34/// A projected decal placed in the world (bullet holes, blood splats, scorch marks).
35#[derive(Debug, Clone)]
36pub struct Decal {
37    pub id:           u32,
38    pub position:     Vec3,
39    pub normal:       Vec3,        // surface normal the decal is projected onto
40    pub rotation:     f32,         // in-plane rotation radians
41    pub size:         Vec2,        // half-extents in world units
42    pub uv_offset:    Vec2,        // UV atlas offset (0..1)
43    pub uv_scale:     Vec2,        // UV atlas scale (0..1)
44    pub color:        Vec4,
45    pub opacity:      f32,
46    pub lifetime:     f32,         // remaining seconds; -1 = permanent
47    pub fade_out_time: f32,        // seconds before death to start fading
48    pub age:          f32,
49    pub category:     DecalCategory,
50    pub layer:        u32,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum DecalCategory {
55    BulletHole,
56    BloodSplat,
57    ScorchMark,
58    Explosion,
59    Footprint,
60    Graffiti,
61    Crack,
62    Water,
63    Custom(u32),
64}
65
66impl Decal {
67    pub fn new(id: u32, pos: Vec3, normal: Vec3) -> Self {
68        Self {
69            id, position: pos, normal,
70            rotation:     0.0,
71            size:         Vec2::new(0.2, 0.2),
72            uv_offset:    Vec2::ZERO,
73            uv_scale:     Vec2::ONE,
74            color:        Vec4::ONE,
75            opacity:      1.0,
76            lifetime:     -1.0,
77            fade_out_time: 2.0,
78            age:          0.0,
79            category:     DecalCategory::Custom(0),
80            layer:        0,
81        }
82    }
83
84    pub fn with_lifetime(mut self, secs: f32) -> Self { self.lifetime = secs; self }
85    pub fn with_color(mut self, c: Vec4) -> Self { self.color = c; self }
86    pub fn with_size(mut self, s: Vec2) -> Self { self.size = s; self }
87    pub fn with_rotation(mut self, r: f32) -> Self { self.rotation = r; self }
88
89    /// Projection matrix: oriented box in world space.
90    pub fn projection_matrix(&self) -> Mat4 {
91        let forward = self.normal;
92        let up = if forward.dot(Vec3::Y).abs() < 0.99 { Vec3::Y } else { Vec3::Z };
93        let right = up.cross(forward).normalize_or_zero();
94        let up2 = forward.cross(right).normalize_or_zero();
95        let rot = Mat4::from_cols(
96            (right * self.size.x).extend(0.0),
97            (up2 * self.size.y).extend(0.0),
98            forward.extend(0.0),
99            self.position.extend(1.0),
100        );
101        rot
102    }
103
104    pub fn is_expired(&self) -> bool {
105        self.lifetime > 0.0 && self.age >= self.lifetime
106    }
107
108    pub fn current_opacity(&self) -> f32 {
109        if self.lifetime <= 0.0 { return self.opacity; }
110        let remaining = (self.lifetime - self.age).max(0.0);
111        if remaining < self.fade_out_time {
112            self.opacity * (remaining / self.fade_out_time.max(0.001))
113        } else {
114            self.opacity
115        }
116    }
117
118    pub fn tick(&mut self, dt: f32) { self.age += dt; }
119}
120
121// ─── Decal pool ───────────────────────────────────────────────────────────────
122
123pub struct DecalPool {
124    decals:     Vec<Decal>,
125    next_id:    u32,
126    max_decals: usize,
127}
128
129impl DecalPool {
130    pub fn new(max_decals: usize) -> Self {
131        Self { decals: Vec::with_capacity(max_decals), next_id: 1, max_decals }
132    }
133
134    pub fn spawn(&mut self, pos: Vec3, normal: Vec3) -> u32 {
135        let id = self.next_id;
136        self.next_id += 1;
137        if self.decals.len() >= self.max_decals {
138            // Evict oldest
139            self.decals.remove(0);
140        }
141        self.decals.push(Decal::new(id, pos, normal));
142        id
143    }
144
145    pub fn spawn_configured(&mut self, mut d: Decal) -> u32 {
146        let id = self.next_id;
147        self.next_id += 1;
148        d.id = id;
149        if self.decals.len() >= self.max_decals {
150            self.decals.remove(0);
151        }
152        self.decals.push(d);
153        id
154    }
155
156    pub fn get_mut(&mut self, id: u32) -> Option<&mut Decal> {
157        self.decals.iter_mut().find(|d| d.id == id)
158    }
159
160    pub fn tick(&mut self, dt: f32) {
161        for d in &mut self.decals { d.tick(dt); }
162        self.decals.retain(|d| !d.is_expired());
163    }
164
165    pub fn visible_decals(&self) -> &[Decal] { &self.decals }
166
167    pub fn clear_category(&mut self, cat: DecalCategory) {
168        self.decals.retain(|d| d.category != cat);
169    }
170
171    pub fn count(&self) -> usize { self.decals.len() }
172}
173
174// ─── Trail ────────────────────────────────────────────────────────────────────
175
176/// A single trail point.
177#[derive(Debug, Clone)]
178pub struct TrailPoint {
179    pub position: Vec3,
180    pub width:    f32,
181    pub color:    Vec4,
182    pub time:     f32,
183}
184
185/// A ribbon trail following a moving object.
186#[derive(Debug, Clone)]
187pub struct Trail {
188    pub id:          u32,
189    pub points:      Vec<TrailPoint>,
190    pub max_points:  usize,
191    pub lifetime:    f32,   // how long each point lives
192    pub min_distance: f32,  // minimum distance to emit a new point
193    pub width_start:  f32,
194    pub width_end:    f32,
195    pub color_start:  Vec4,
196    pub color_end:    Vec4,
197    pub time:         f32,
198    pub enabled:      bool,
199    pub smooth:       bool,
200}
201
202impl Trail {
203    pub fn new(id: u32) -> Self {
204        Self {
205            id, points: Vec::new(), max_points: 64,
206            lifetime: 1.5, min_distance: 0.05,
207            width_start: 0.1, width_end: 0.0,
208            color_start: Vec4::ONE,
209            color_end:   Vec4::new(1.0, 1.0, 1.0, 0.0),
210            time: 0.0, enabled: true, smooth: true,
211        }
212    }
213
214    pub fn emit(&mut self, pos: Vec3) {
215        if let Some(last) = self.points.last() {
216            if (pos - last.position).length() < self.min_distance { return; }
217        }
218        if self.points.len() >= self.max_points {
219            self.points.remove(0);
220        }
221        self.points.push(TrailPoint {
222            position: pos,
223            width: self.width_start,
224            color: self.color_start,
225            time: 0.0,
226        });
227    }
228
229    pub fn tick(&mut self, dt: f32) {
230        self.time += dt;
231        for p in &mut self.points { p.time += dt; }
232
233        // Expire old points and update width/color by age fraction
234        self.points.retain(|p| p.time < self.lifetime);
235        for p in &mut self.points {
236            let t = p.time / self.lifetime.max(0.001);
237            p.width = self.width_start + t * (self.width_end - self.width_start);
238            // Lerp color
239            let r = self.color_start.x + t * (self.color_end.x - self.color_start.x);
240            let g = self.color_start.y + t * (self.color_end.y - self.color_start.y);
241            let b = self.color_start.z + t * (self.color_end.z - self.color_start.z);
242            let a = self.color_start.w + t * (self.color_end.w - self.color_start.w);
243            p.color = Vec4::new(r, g, b, a);
244        }
245    }
246
247    pub fn is_empty(&self) -> bool { self.points.is_empty() }
248
249    /// Generate ribbon vertices (position, uv, color) for rendering.
250    pub fn generate_ribbon(&self) -> Vec<(Vec3, Vec2, Vec4)> {
251        if self.points.len() < 2 { return Vec::new(); }
252        let mut verts = Vec::new();
253        let total = self.points.len();
254
255        for i in 0..total {
256            let p = &self.points[i];
257            let fwd = if i + 1 < total {
258                (self.points[i + 1].position - p.position).normalize_or_zero()
259            } else if i > 0 {
260                (p.position - self.points[i - 1].position).normalize_or_zero()
261            } else {
262                Vec3::X
263            };
264
265            let up = Vec3::Y;
266            let right = fwd.cross(up).normalize_or_zero();
267            let half_w = p.width * 0.5;
268            let u = i as f32 / (total - 1) as f32;
269
270            verts.push((p.position - right * half_w, Vec2::new(u, 0.0), p.color));
271            verts.push((p.position + right * half_w, Vec2::new(u, 1.0), p.color));
272        }
273        verts
274    }
275}
276
277// ─── Trail manager ────────────────────────────────────────────────────────────
278
279pub struct TrailManager {
280    trails:  HashMap<u32, Trail>,
281    next_id: u32,
282}
283
284impl TrailManager {
285    pub fn new() -> Self {
286        Self { trails: HashMap::new(), next_id: 1 }
287    }
288
289    pub fn create(&mut self) -> u32 {
290        let id = self.next_id; self.next_id += 1;
291        self.trails.insert(id, Trail::new(id));
292        id
293    }
294
295    pub fn get(&self, id: u32) -> Option<&Trail> { self.trails.get(&id) }
296    pub fn get_mut(&mut self, id: u32) -> Option<&mut Trail> { self.trails.get_mut(&id) }
297
298    pub fn emit(&mut self, id: u32, pos: Vec3) {
299        if let Some(t) = self.trails.get_mut(&id) { t.emit(pos); }
300    }
301
302    pub fn remove(&mut self, id: u32) { self.trails.remove(&id); }
303
304    pub fn tick(&mut self, dt: f32) {
305        for t in self.trails.values_mut() { t.tick(dt); }
306    }
307
308    pub fn all_trails(&self) -> impl Iterator<Item = &Trail> {
309        self.trails.values()
310    }
311}
312
313// ─── Impact effect ────────────────────────────────────────────────────────────
314
315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316pub enum ImpactType {
317    Bullet,
318    Explosion,
319    Slash,
320    Magic,
321    Fire,
322    Ice,
323    Lightning,
324    Custom(u32),
325}
326
327/// A spawned impact effect (flash + sparks + decal + sound).
328#[derive(Debug, Clone)]
329pub struct ImpactEffect {
330    pub id:       u32,
331    pub position: Vec3,
332    pub normal:   Vec3,
333    pub kind:     ImpactType,
334    pub power:    f32,   // 0..1 normalized intensity
335    pub age:      f32,
336    pub duration: f32,
337    pub color:    Vec4,
338    pub spawned_decal_id: Option<u32>,
339    pub spawned_trail_id: Option<u32>,
340}
341
342impl ImpactEffect {
343    pub fn new(id: u32, pos: Vec3, normal: Vec3, kind: ImpactType, power: f32) -> Self {
344        let (color, duration) = match kind {
345            ImpactType::Fire      => (Vec4::new(1.0, 0.5, 0.1, 1.0), 0.8),
346            ImpactType::Ice       => (Vec4::new(0.5, 0.8, 1.0, 1.0), 0.6),
347            ImpactType::Lightning => (Vec4::new(0.9, 0.9, 0.2, 1.0), 0.3),
348            ImpactType::Magic     => (Vec4::new(0.8, 0.2, 1.0, 1.0), 0.7),
349            ImpactType::Explosion => (Vec4::new(1.0, 0.6, 0.1, 1.0), 1.0),
350            _                     => (Vec4::ONE, 0.4),
351        };
352        Self {
353            id, position: pos, normal, kind, power,
354            age: 0.0, duration, color,
355            spawned_decal_id: None,
356            spawned_trail_id: None,
357        }
358    }
359
360    pub fn is_done(&self) -> bool { self.age >= self.duration }
361    pub fn progress(&self) -> f32 { (self.age / self.duration.max(0.001)).min(1.0) }
362    pub fn tick(&mut self, dt: f32) { self.age += dt; }
363}
364
365// ─── VFX spawn descriptor ─────────────────────────────────────────────────────
366
367/// High-level VFX spawn command.
368#[derive(Debug, Clone)]
369pub enum VfxCommand {
370    SpawnDecal { pos: Vec3, normal: Vec3, category: DecalCategory, size: Vec2, color: Vec4, lifetime: f32 },
371    SpawnImpact { pos: Vec3, normal: Vec3, kind: ImpactType, power: f32 },
372    SpawnTrail  { attach_to: u64, color_start: Vec4, color_end: Vec4, width: f32, lifetime: f32 },
373    RemoveTrail { trail_id: u32 },
374    Shockwave   { center: Vec3, radius: f32, thickness: f32, speed: f32, color: Vec4 },
375    ScreenFlash { color: Vec4, duration: f32 },
376}
377
378// ─── Shockwave ────────────────────────────────────────────────────────────────
379
380/// An expanding shockwave ring effect.
381#[derive(Debug, Clone)]
382pub struct Shockwave {
383    pub id:        u32,
384    pub center:    Vec3,
385    pub radius:    f32,        // current radius
386    pub max_radius: f32,
387    pub thickness: f32,
388    pub speed:     f32,        // expansion speed (units/sec)
389    pub color:     Vec4,
390    pub age:       f32,
391    pub duration:  f32,
392}
393
394impl Shockwave {
395    pub fn new(id: u32, center: Vec3, max_radius: f32, speed: f32, color: Vec4) -> Self {
396        Self {
397            id, center,
398            radius:     0.0,
399            max_radius,
400            thickness:  max_radius * 0.1,
401            speed, color, age: 0.0,
402            duration:   max_radius / speed.max(0.001),
403        }
404    }
405
406    pub fn tick(&mut self, dt: f32) {
407        self.age += dt;
408        self.radius = (self.speed * self.age).min(self.max_radius);
409    }
410
411    pub fn alpha(&self) -> f32 {
412        let t = self.age / self.duration.max(0.001);
413        (1.0 - t * t).max(0.0)
414    }
415
416    pub fn is_done(&self) -> bool { self.radius >= self.max_radius }
417}
418
419// ─── Screen flash ─────────────────────────────────────────────────────────────
420
421/// A full-screen flash overlay effect.
422#[derive(Debug, Clone)]
423pub struct ScreenFlash {
424    pub color:    Vec4,
425    pub duration: f32,
426    pub age:      f32,
427}
428
429impl ScreenFlash {
430    pub fn new(color: Vec4, duration: f32) -> Self {
431        Self { color, duration, age: 0.0 }
432    }
433
434    pub fn alpha(&self) -> f32 {
435        let t = self.age / self.duration.max(0.001);
436        self.color.w * (1.0 - t).max(0.0)
437    }
438
439    pub fn tick(&mut self, dt: f32) { self.age += dt; }
440    pub fn is_done(&self) -> bool { self.age >= self.duration }
441}
442
443// ─── VFX Manager ─────────────────────────────────────────────────────────────
444
445/// Central VFX coordinator.
446pub struct VfxManager {
447    pub decals:       DecalPool,
448    pub trails:       TrailManager,
449    pub impacts:      Vec<ImpactEffect>,
450    pub shockwaves:   Vec<Shockwave>,
451    pub flashes:      Vec<ScreenFlash>,
452    next_effect_id:   u32,
453    pub command_queue: Vec<VfxCommand>,
454}
455
456impl VfxManager {
457    pub fn new() -> Self {
458        Self {
459            decals:       DecalPool::new(512),
460            trails:       TrailManager::new(),
461            impacts:      Vec::new(),
462            shockwaves:   Vec::new(),
463            flashes:      Vec::new(),
464            next_effect_id: 1,
465            command_queue: Vec::new(),
466        }
467    }
468
469    fn alloc_id(&mut self) -> u32 {
470        let id = self.next_effect_id; self.next_effect_id += 1; id
471    }
472
473    pub fn queue(&mut self, cmd: VfxCommand) {
474        self.command_queue.push(cmd);
475    }
476
477    pub fn flush_commands(&mut self) {
478        let cmds = std::mem::take(&mut self.command_queue);
479        for cmd in cmds {
480            self.execute(cmd);
481        }
482    }
483
484    pub fn execute(&mut self, cmd: VfxCommand) {
485        match cmd {
486            VfxCommand::SpawnDecal { pos, normal, category, size, color, lifetime } => {
487                let mut d = Decal::new(0, pos, normal);
488                d.category = category;
489                d.size = size;
490                d.color = color;
491                d.lifetime = lifetime;
492                self.decals.spawn_configured(d);
493            }
494            VfxCommand::SpawnImpact { pos, normal, kind, power } => {
495                let id = self.alloc_id();
496                self.impacts.push(ImpactEffect::new(id, pos, normal, kind, power));
497            }
498            VfxCommand::SpawnTrail { attach_to: _, color_start, color_end, width, lifetime } => {
499                let tid = self.trails.create();
500                if let Some(t) = self.trails.get_mut(tid) {
501                    t.color_start = color_start;
502                    t.color_end   = color_end;
503                    t.width_start = width;
504                    t.lifetime    = lifetime;
505                }
506            }
507            VfxCommand::RemoveTrail { trail_id } => {
508                self.trails.remove(trail_id);
509            }
510            VfxCommand::Shockwave { center, radius, thickness, speed, color } => {
511                let id = self.alloc_id();
512                let mut sw = Shockwave::new(id, center, radius, speed, color);
513                sw.thickness = thickness;
514                self.shockwaves.push(sw);
515            }
516            VfxCommand::ScreenFlash { color, duration } => {
517                self.flashes.push(ScreenFlash::new(color, duration));
518            }
519        }
520    }
521
522    pub fn tick(&mut self, dt: f32) {
523        self.flush_commands();
524        self.decals.tick(dt);
525        self.trails.tick(dt);
526        for e in &mut self.impacts   { e.tick(dt); }
527        for s in &mut self.shockwaves { s.tick(dt); }
528        for f in &mut self.flashes   { f.tick(dt); }
529        self.impacts.retain(|e| !e.is_done());
530        self.shockwaves.retain(|s| !s.is_done());
531        self.flashes.retain(|f| !f.is_done());
532    }
533
534    /// Dominant screen flash color (additive blend of active flashes).
535    pub fn screen_flash_color(&self) -> Vec4 {
536        let mut out = Vec4::ZERO;
537        for f in &self.flashes {
538            let a = f.alpha();
539            out += Vec4::new(f.color.x * a, f.color.y * a, f.color.z * a, a);
540        }
541        out
542    }
543}
544
545// ─── Procedural hit flash ─────────────────────────────────────────────────────
546
547/// Flashes an entity's material white on hit.
548#[derive(Debug, Clone)]
549pub struct HitFlash {
550    pub intensity: f32,
551    pub decay:     f32,  // intensity drop per second
552}
553
554impl HitFlash {
555    pub fn new() -> Self { Self { intensity: 0.0, decay: 8.0 } }
556
557    pub fn trigger(&mut self, amount: f32) {
558        self.intensity = (self.intensity + amount).min(1.0);
559    }
560
561    pub fn tick(&mut self, dt: f32) {
562        self.intensity = (self.intensity - self.decay * dt).max(0.0);
563    }
564
565    pub fn value(&self) -> f32 { self.intensity }
566}
567
568// ─── Dissolve effect ──────────────────────────────────────────────────────────
569
570/// Dissolve/burn-away effect driven by a noise threshold.
571#[derive(Debug, Clone)]
572pub struct DissolveEffect {
573    pub threshold: f32,   // 0 = fully visible, 1 = fully dissolved
574    pub edge_width: f32,
575    pub edge_color: Vec4,
576    pub speed:      f32,
577    pub dissolving: bool,
578    pub reassembling: bool,
579}
580
581impl DissolveEffect {
582    pub fn new() -> Self {
583        Self {
584            threshold: 0.0, edge_width: 0.05,
585            edge_color: Vec4::new(1.0, 0.5, 0.0, 1.0),
586            speed: 1.0, dissolving: false, reassembling: false,
587        }
588    }
589
590    pub fn start_dissolve(&mut self) { self.dissolving = true; self.reassembling = false; }
591    pub fn start_reassemble(&mut self) { self.reassembling = true; self.dissolving = false; }
592
593    pub fn tick(&mut self, dt: f32) {
594        if self.dissolving {
595            self.threshold = (self.threshold + self.speed * dt).min(1.0);
596            if self.threshold >= 1.0 { self.dissolving = false; }
597        } else if self.reassembling {
598            self.threshold = (self.threshold - self.speed * dt).max(0.0);
599            if self.threshold <= 0.0 { self.reassembling = false; }
600        }
601    }
602
603    pub fn is_fully_dissolved(&self) -> bool { self.threshold >= 1.0 }
604    pub fn is_fully_visible(&self) -> bool { self.threshold <= 0.0 }
605}
606
607// ─── Outline effect ───────────────────────────────────────────────────────────
608
609/// Object outline / silhouette highlight.
610#[derive(Debug, Clone)]
611pub struct OutlineEffect {
612    pub color:     Vec4,
613    pub width:     f32,   // pixels
614    pub enabled:   bool,
615    pub pulse:     bool,
616    pub pulse_speed: f32,
617    pub pulse_min: f32,
618    pub pulse_max: f32,
619    time:          f32,
620}
621
622impl OutlineEffect {
623    pub fn new(color: Vec4, width: f32) -> Self {
624        Self { color, width, enabled: true, pulse: false, pulse_speed: 2.0, pulse_min: 0.5, pulse_max: 1.0, time: 0.0 }
625    }
626
627    pub fn tick(&mut self, dt: f32) { self.time += dt; }
628
629    pub fn current_width(&self) -> f32 {
630        if !self.pulse { return self.width; }
631        let t = (self.time * self.pulse_speed * std::f32::consts::TAU).sin() * 0.5 + 0.5;
632        let w = self.pulse_min + t * (self.pulse_max - self.pulse_min);
633        self.width * w
634    }
635}
636
637// ─── Electricity arc ──────────────────────────────────────────────────────────
638
639/// Procedural electricity arc between two points (used for lightning weapons, Tesla coils, etc.).
640#[derive(Debug, Clone)]
641pub struct ElectricArc {
642    pub id:         u32,
643    pub start:      Vec3,
644    pub end:        Vec3,
645    pub segments:   u32,
646    pub jitter:     f32,   // max displacement per segment
647    pub color:      Vec4,
648    pub width:      f32,
649    pub lifetime:   f32,
650    pub age:        f32,
651    pub flicker:    bool,
652    pub visible:    bool,
653    pub seed:       u32,
654}
655
656impl ElectricArc {
657    pub fn new(id: u32, start: Vec3, end: Vec3) -> Self {
658        Self {
659            id, start, end, segments: 12, jitter: 0.3,
660            color: Vec4::new(0.7, 0.8, 1.0, 0.9),
661            width: 0.03, lifetime: 0.2, age: 0.0, flicker: true, visible: true, seed: id,
662        }
663    }
664
665    pub fn tick(&mut self, dt: f32) { self.age += dt; }
666    pub fn is_done(&self) -> bool { self.age >= self.lifetime }
667    pub fn alpha(&self) -> f32 { (1.0 - self.age / self.lifetime.max(0.001)).max(0.0) }
668
669    /// Generate segmented lightning path using LCG pseudo-random.
670    pub fn generate_points(&self) -> Vec<Vec3> {
671        let mut rng_state = self.seed.wrapping_mul(2654435761).wrapping_add(self.age.to_bits());
672        let next_f = |s: &mut u32| -> f32 {
673            *s = s.wrapping_mul(1664525).wrapping_add(1013904223);
674            (*s as f32 / u32::MAX as f32) * 2.0 - 1.0
675        };
676
677        let n = self.segments as usize;
678        let mut pts = Vec::with_capacity(n + 2);
679        pts.push(self.start);
680
681        for i in 1..=n {
682            let t = i as f32 / (n + 1) as f32;
683            let base = self.start + (self.end - self.start) * t;
684            let perpendicular = {
685                let dir = (self.end - self.start).normalize_or_zero();
686                let up = if dir.dot(Vec3::Y).abs() < 0.9 { Vec3::Y } else { Vec3::Z };
687                let right = dir.cross(up).normalize_or_zero();
688                let up2 = dir.cross(right).normalize_or_zero();
689                right * next_f(&mut rng_state) + up2 * next_f(&mut rng_state)
690            };
691            pts.push(base + perpendicular * self.jitter);
692        }
693
694        pts.push(self.end);
695        pts
696    }
697}
698
699// ─── VFX preset library ───────────────────────────────────────────────────────
700
701impl VfxManager {
702    /// Spawn a bullet impact effect at world position.
703    pub fn bullet_impact(&mut self, pos: Vec3, normal: Vec3, material: BulletMaterial) {
704        let (color, sparks) = match material {
705            BulletMaterial::Metal   => (Vec4::new(1.0, 0.8, 0.3, 1.0), true),
706            BulletMaterial::Stone   => (Vec4::new(0.7, 0.6, 0.5, 1.0), false),
707            BulletMaterial::Wood    => (Vec4::new(0.6, 0.4, 0.2, 1.0), false),
708            BulletMaterial::Flesh   => (Vec4::new(0.8, 0.1, 0.1, 1.0), false),
709            BulletMaterial::Glass   => (Vec4::new(0.8, 0.9, 1.0, 0.7), false),
710            BulletMaterial::Energy  => (Vec4::new(0.5, 0.3, 1.0, 1.0), true),
711        };
712
713        self.queue(VfxCommand::SpawnDecal {
714            pos, normal,
715            category: DecalCategory::BulletHole,
716            size: Vec2::splat(0.05 + 0.02 * (if sparks { 1.0 } else { 0.0 })),
717            color, lifetime: 30.0,
718        });
719        self.queue(VfxCommand::SpawnImpact { pos, normal, kind: ImpactType::Bullet, power: 0.5 });
720    }
721
722    /// Spawn an explosion effect.
723    pub fn explosion(&mut self, center: Vec3, radius: f32, power: f32) {
724        self.queue(VfxCommand::SpawnDecal {
725            pos: center - Vec3::Y * 0.01,
726            normal: Vec3::Y,
727            category: DecalCategory::Explosion,
728            size: Vec2::splat(radius * 0.8),
729            color: Vec4::new(0.3, 0.2, 0.1, 0.8),
730            lifetime: 60.0,
731        });
732        self.queue(VfxCommand::SpawnImpact {
733            pos: center, normal: Vec3::Y,
734            kind: ImpactType::Explosion, power,
735        });
736        self.queue(VfxCommand::Shockwave {
737            center, radius: radius * 1.5, thickness: radius * 0.15,
738            speed: radius * 3.0, color: Vec4::new(1.0, 0.8, 0.5, 0.6),
739        });
740        self.queue(VfxCommand::ScreenFlash {
741            color: Vec4::new(1.0, 0.9, 0.7, power * 0.7),
742            duration: 0.15 + power * 0.1,
743        });
744    }
745
746    /// Spawn a magic spell impact.
747    pub fn magic_impact(&mut self, pos: Vec3, color: Vec4, radius: f32) {
748        self.queue(VfxCommand::SpawnImpact { pos, normal: Vec3::Y, kind: ImpactType::Magic, power: 0.8 });
749        self.queue(VfxCommand::Shockwave {
750            center: pos, radius, thickness: radius * 0.08,
751            speed: radius * 4.0, color,
752        });
753    }
754}
755
756/// Material type for bullet impacts.
757#[derive(Debug, Clone, Copy)]
758pub enum BulletMaterial {
759    Metal, Stone, Wood, Flesh, Glass, Energy,
760}
761
762// ─── Particle burst descriptor ────────────────────────────────────────────────
763
764/// Compact descriptor for a particle burst spawned by VFX.
765#[derive(Debug, Clone)]
766pub struct BurstDescriptor {
767    pub origin:    Vec3,
768    pub direction: Vec3,
769    pub spread:    f32,   // cone half-angle radians
770    pub count:     u32,
771    pub speed_min: f32,
772    pub speed_max: f32,
773    pub size_min:  f32,
774    pub size_max:  f32,
775    pub lifetime_min: f32,
776    pub lifetime_max: f32,
777    pub color:     Vec4,
778    pub gravity:   Vec3,
779}
780
781impl BurstDescriptor {
782    pub fn explosion_sparks(origin: Vec3) -> Self {
783        Self {
784            origin, direction: Vec3::Y, spread: std::f32::consts::PI,
785            count: 24, speed_min: 1.5, speed_max: 4.0,
786            size_min: 0.02, size_max: 0.06,
787            lifetime_min: 0.3, lifetime_max: 0.9,
788            color: Vec4::new(1.0, 0.6, 0.1, 1.0),
789            gravity: Vec3::new(0.0, -9.8, 0.0),
790        }
791    }
792
793    pub fn magic_burst(origin: Vec3, color: Vec4) -> Self {
794        Self {
795            origin, direction: Vec3::Y, spread: std::f32::consts::PI,
796            count: 32, speed_min: 0.5, speed_max: 2.0,
797            size_min: 0.03, size_max: 0.08,
798            lifetime_min: 0.5, lifetime_max: 1.5,
799            color, gravity: Vec3::ZERO,
800        }
801    }
802}
803
804// ─── Default impl ─────────────────────────────────────────────────────────────
805
806impl Default for VfxManager {
807    fn default() -> Self { Self::new() }
808}
809
810impl Default for TrailManager {
811    fn default() -> Self { Self::new() }
812}
813
814impl Default for HitFlash {
815    fn default() -> Self { Self::new() }
816}
817
818impl Default for DissolveEffect {
819    fn default() -> Self { Self::new() }
820}