Skip to main content

proof_engine/effects/
field_viz.rs

1//! Force field visualization — renders directional arrows on a grid showing
2//! the direction and strength of combined force fields in the scene.
3//!
4//! Arrows are rendered as Unicode characters (→ ← ↑ ↓ ↗ ↘ ↙ ↖) with color
5//! and size mapped to field strength.  The visualization is toggled by debug
6//! mode (F2) or activated automatically during specific boss encounters.
7//!
8//! # Grid Layout
9//!
10//! The visualizer samples force fields on a 2D grid in world space:
11//! ```text
12//!   ↗ → ↘ ↓ ↙    (arrows show force direction at each sample point)
13//!   → → ↘ ↓ ↙
14//!   ↑ ↗ ● ↙ ↓    (● = gravity well center)
15//!   ↗ → ↗ ↓ ↙
16//!   → → → ↘ ↓
17//! ```
18//!
19//! # Boss-specific overlays
20//!
21//! - The Algorithm Reborn: adaptation field distortion
22//! - The Null: void field consuming nearby arrows
23//! - The Ouroboros: healing flow shown as golden arrows
24//! - Any gravity well: concentric inward-pointing rings
25//! - Shockwave: expanding outward ring
26
27use glam::{Vec2, Vec3, Vec4};
28use std::collections::HashMap;
29
30use crate::glyph::{Glyph, GlyphId, GlyphPool, RenderLayer, BlendMode};
31use crate::math::fields::ForceField;
32use crate::scene::field_manager::{FieldManager, FieldSample};
33
34// ── Arrow characters ────────────────────────────────────────────────────────
35
36/// Map a 2D direction vector to the closest Unicode arrow character.
37fn direction_to_arrow(dir: Vec2) -> char {
38    if dir.length_squared() < 0.0001 {
39        return '·';
40    }
41    let angle = dir.y.atan2(dir.x);
42    let octant = ((angle + std::f32::consts::PI) / (std::f32::consts::PI / 4.0)).floor() as i32 % 8;
43    match octant {
44        0 => '←',
45        1 => '↙',
46        2 => '↓',
47        3 => '↘',
48        4 => '→',
49        5 => '↗',
50        6 => '↑',
51        7 => '↖',
52        _ => '→',
53    }
54}
55
56/// Get a secondary arrow for diagonal-adjacent visualization.
57fn direction_to_heavy_arrow(dir: Vec2) -> char {
58    if dir.length_squared() < 0.0001 {
59        return '○';
60    }
61    let angle = dir.y.atan2(dir.x);
62    let quadrant = ((angle + std::f32::consts::PI) / (std::f32::consts::PI / 2.0)).floor() as i32 % 4;
63    match quadrant {
64        0 => '◀',
65        1 => '▼',
66        2 => '▶',
67        3 => '▲',
68        _ => '▶',
69    }
70}
71
72// ── Strength-to-color gradient ──────────────────────────────────────────────
73
74/// Map field strength (0.0 = zero, 1.0+ = strong) to a color.
75///
76/// Gradient: dim blue → cyan → green → yellow → red
77fn strength_to_color(strength: f32) -> Vec4 {
78    let t = strength.clamp(0.0, 2.0) / 2.0;
79    let alpha = (0.3 + strength.min(1.0) * 0.7).min(1.0);
80
81    if t < 0.25 {
82        let s = t / 0.25;
83        Vec4::new(0.1, 0.2 + s * 0.3, 0.5 + s * 0.5, alpha)  // dim blue → cyan
84    } else if t < 0.5 {
85        let s = (t - 0.25) / 0.25;
86        Vec4::new(0.1 * (1.0 - s), 0.5 + s * 0.5, 1.0 - s * 0.5, alpha) // cyan → green
87    } else if t < 0.75 {
88        let s = (t - 0.5) / 0.25;
89        Vec4::new(s, 1.0, (1.0 - s) * 0.5, alpha) // green → yellow
90    } else {
91        let s = (t - 0.75) / 0.25;
92        Vec4::new(1.0, 1.0 - s * 0.7, 0.0, alpha) // yellow → red
93    }
94}
95
96/// Map temperature to a heat color (cold blue → hot red).
97fn temperature_to_color(temp: f32) -> Vec4 {
98    let t = temp.clamp(0.0, 2.0) / 2.0;
99    Vec4::new(t, 0.2 * (1.0 - t), 1.0 - t, 0.6 + t * 0.4)
100}
101
102/// Map entropy to a chaos color (ordered white → chaotic magenta).
103fn entropy_to_color(entropy: f32) -> Vec4 {
104    let t = entropy.clamp(0.0, 1.0);
105    Vec4::new(0.6 + t * 0.4, 0.3 * (1.0 - t), 0.5 + t * 0.5, 0.5 + t * 0.5)
106}
107
108// ── Grid Configuration ──────────────────────────────────────────────────────
109
110/// Configuration for the force field visualization grid.
111#[derive(Clone, Debug)]
112pub struct FieldVizConfig {
113    /// Number of sample columns.
114    pub cols: u32,
115    /// Number of sample rows.
116    pub rows: u32,
117    /// Spacing between sample points in world units.
118    pub spacing: f32,
119    /// How often to re-sample (seconds between updates).
120    pub update_interval: f32,
121    /// Minimum strength to display an arrow.
122    pub strength_threshold: f32,
123    /// Maximum glyph scale for arrows.
124    pub max_arrow_scale: f32,
125    /// Z depth for the visualization layer.
126    pub z_depth: f32,
127    /// Whether to show temperature overlay.
128    pub show_temperature: bool,
129    /// Whether to show entropy overlay.
130    pub show_entropy: bool,
131    /// Render layer for visualization glyphs.
132    pub layer: RenderLayer,
133    /// Blend mode for visualization glyphs.
134    pub blend: BlendMode,
135    /// Emission on arrows (for glow).
136    pub arrow_emission: f32,
137}
138
139impl Default for FieldVizConfig {
140    fn default() -> Self {
141        Self {
142            cols: 40,
143            rows: 25,
144            spacing: 1.2,
145            update_interval: 0.05,
146            strength_threshold: 0.01,
147            max_arrow_scale: 1.5,
148            z_depth: -1.0,
149            show_temperature: false,
150            show_entropy: false,
151            layer: RenderLayer::Overlay,
152            blend: BlendMode::Additive,
153            arrow_emission: 0.3,
154        }
155    }
156}
157
158impl FieldVizConfig {
159    /// High-density debug grid.
160    pub fn debug() -> Self {
161        Self {
162            cols: 60,
163            rows: 35,
164            spacing: 0.8,
165            update_interval: 0.03,
166            show_temperature: true,
167            show_entropy: true,
168            arrow_emission: 0.5,
169            ..Self::default()
170        }
171    }
172
173    /// Sparse boss-fight overlay.
174    pub fn boss_overlay() -> Self {
175        Self {
176            cols: 30,
177            rows: 20,
178            spacing: 1.5,
179            update_interval: 0.04,
180            arrow_emission: 0.6,
181            ..Self::default()
182        }
183    }
184
185    /// Minimal ambient visualization.
186    pub fn ambient() -> Self {
187        Self {
188            cols: 20,
189            rows: 15,
190            spacing: 2.0,
191            update_interval: 0.1,
192            strength_threshold: 0.05,
193            arrow_emission: 0.15,
194            ..Self::default()
195        }
196    }
197}
198
199// ── Sample Point ────────────────────────────────────────────────────────────
200
201/// A single sample point on the visualization grid.
202#[derive(Clone, Debug)]
203struct SamplePoint {
204    /// World-space position.
205    world_pos: Vec2,
206    /// Sampled force direction (normalized).
207    direction: Vec2,
208    /// Sampled force magnitude.
209    strength: f32,
210    /// Sampled temperature.
211    temperature: f32,
212    /// Sampled entropy.
213    entropy: f32,
214    /// Number of contributing fields.
215    field_count: usize,
216    /// The glyph ID for the arrow at this point (None if culled).
217    glyph_id: Option<GlyphId>,
218}
219
220// ── FieldVisualizer ─────────────────────────────────────────────────────────
221
222/// Force field visualization system.
223///
224/// Maintains a grid of sample points, periodically re-samples the force field,
225/// and updates glyph positions/colors/characters to show the field state.
226pub struct FieldVisualizer {
227    /// Grid of sample points.
228    sample_points: Vec<SamplePoint>,
229    /// Visualization configuration.
230    pub config: FieldVizConfig,
231    /// Center of the visualization grid in world space.
232    pub center: Vec2,
233    /// Time accumulator for update interval.
234    update_timer: f32,
235    /// Whether the visualizer is currently active.
236    pub active: bool,
237    /// Active boss-specific overlays.
238    pub boss_overlays: Vec<BossFieldOverlay>,
239    /// Shockwave rings currently expanding.
240    shockwave_rings: Vec<ShockwaveRing>,
241    /// Gravity well ring visualizations.
242    gravity_wells: Vec<GravityWellViz>,
243    /// Performance: last sample time in microseconds.
244    pub last_sample_us: u32,
245}
246
247impl FieldVisualizer {
248    /// Create a new field visualizer centered at `center`.
249    pub fn new(center: Vec2, config: FieldVizConfig) -> Self {
250        let total = (config.cols * config.rows) as usize;
251        let half_w = config.cols as f32 * config.spacing * 0.5;
252        let half_h = config.rows as f32 * config.spacing * 0.5;
253
254        let mut sample_points = Vec::with_capacity(total);
255        for row in 0..config.rows {
256            for col in 0..config.cols {
257                let x = center.x - half_w + col as f32 * config.spacing;
258                let y = center.y - half_h + row as f32 * config.spacing;
259                sample_points.push(SamplePoint {
260                    world_pos: Vec2::new(x, y),
261                    direction: Vec2::ZERO,
262                    strength: 0.0,
263                    temperature: 0.0,
264                    entropy: 0.0,
265                    field_count: 0,
266                    glyph_id: None,
267                });
268            }
269        }
270
271        Self {
272            sample_points,
273            config,
274            center,
275            update_timer: 0.0,
276            active: false,
277            boss_overlays: Vec::new(),
278            shockwave_rings: Vec::new(),
279            gravity_wells: Vec::new(),
280            last_sample_us: 0,
281        }
282    }
283
284    /// Re-center the visualization grid.
285    pub fn set_center(&mut self, center: Vec2) {
286        self.center = center;
287        let half_w = self.config.cols as f32 * self.config.spacing * 0.5;
288        let half_h = self.config.rows as f32 * self.config.spacing * 0.5;
289        for (i, point) in self.sample_points.iter_mut().enumerate() {
290            let col = i as u32 % self.config.cols;
291            let row = i as u32 / self.config.cols;
292            point.world_pos = Vec2::new(
293                center.x - half_w + col as f32 * self.config.spacing,
294                center.y - half_h + row as f32 * self.config.spacing,
295            );
296        }
297    }
298
299    /// Toggle the visualizer on/off.
300    pub fn toggle(&mut self) {
301        self.active = !self.active;
302    }
303
304    /// Sample the force fields and update the grid.
305    ///
306    /// `field_mgr` provides the combined field evaluation.
307    /// `time` is the current scene time.
308    pub fn tick(&mut self, dt: f32, field_mgr: &FieldManager, time: f32) {
309        // Tick shockwaves and gravity wells regardless of sample interval.
310        self.tick_shockwaves(dt);
311        self.tick_boss_overlays(dt);
312
313        if !self.active {
314            return;
315        }
316
317        self.update_timer += dt;
318        if self.update_timer < self.config.update_interval {
319            return;
320        }
321        self.update_timer = 0.0;
322
323        let start = std::time::Instant::now();
324
325        for point in &mut self.sample_points {
326            let pos3 = Vec3::new(point.world_pos.x, point.world_pos.y, 0.0);
327            let sample = field_mgr.sample(pos3, 1.0, 0.0, time);
328
329            let force_2d = Vec2::new(sample.force.x, sample.force.y);
330            let strength = force_2d.length();
331
332            point.direction = if strength > 0.0001 { force_2d / strength } else { Vec2::ZERO };
333            point.strength = strength;
334            point.temperature = sample.temperature;
335            point.entropy = sample.entropy;
336            point.field_count = sample.field_count;
337
338            // Apply boss overlay modifications.
339            for overlay in &self.boss_overlays {
340                overlay.modify_sample(point);
341            }
342
343            // Apply shockwave modifications.
344            for ring in &self.shockwave_rings {
345                ring.modify_sample(point);
346            }
347        }
348
349        self.last_sample_us = start.elapsed().as_micros() as u32;
350    }
351
352    /// Spawn/update visualization glyphs in the glyph pool.
353    ///
354    /// Call after `tick()`.  Creates or updates glyphs for each sample point
355    /// that exceeds the strength threshold.
356    pub fn update_glyphs(&mut self, pool: &mut GlyphPool) {
357        if !self.active {
358            // Despawn all glyphs.
359            for point in &mut self.sample_points {
360                if let Some(id) = point.glyph_id.take() {
361                    pool.despawn(id);
362                }
363            }
364            return;
365        }
366
367        let config = self.config.clone();
368        for point in &mut self.sample_points {
369            let visible = point.strength >= config.strength_threshold;
370
371            if visible {
372                let arrow = direction_to_arrow(point.direction);
373                let color = compute_point_color_static(&config, point);
374                let scale = compute_point_scale_static(&config, point);
375                let emission = self.config.arrow_emission * (point.strength / 1.0).min(2.0);
376
377                if let Some(id) = point.glyph_id {
378                    // Update existing glyph.
379                    if let Some(glyph) = pool.get_mut(id) {
380                        glyph.character = arrow;
381                        glyph.position = Vec3::new(point.world_pos.x, point.world_pos.y, self.config.z_depth);
382                        glyph.color = color;
383                        glyph.scale = Vec2::splat(scale);
384                        glyph.emission = emission;
385                        glyph.glow_color = Vec3::new(color.x, color.y, color.z);
386                        glyph.glow_radius = point.strength.min(1.0) * 0.5;
387                    }
388                } else {
389                    // Spawn new glyph.
390                    let glyph = Glyph {
391                        character: arrow,
392                        position: Vec3::new(point.world_pos.x, point.world_pos.y, self.config.z_depth),
393                        color,
394                        scale: Vec2::splat(scale),
395                        emission,
396                        glow_color: Vec3::new(color.x, color.y, color.z),
397                        glow_radius: point.strength.min(1.0) * 0.5,
398                        layer: self.config.layer,
399                        blend_mode: self.config.blend,
400                        visible: true,
401                        ..Glyph::default()
402                    };
403                    point.glyph_id = Some(pool.spawn(glyph));
404                }
405            } else {
406                // Remove glyph if below threshold.
407                if let Some(id) = point.glyph_id.take() {
408                    pool.despawn(id);
409                }
410            }
411        }
412    }
413
414    /// Despawn all visualization glyphs.
415    pub fn despawn_all(&mut self, pool: &mut GlyphPool) {
416        for point in &mut self.sample_points {
417            if let Some(id) = point.glyph_id.take() {
418                pool.despawn(id);
419            }
420        }
421    }
422
423    /// Compute the color for a sample point based on strength + optional temp/entropy.
424    fn compute_point_color(&self, point: &SamplePoint) -> Vec4 {
425        let mut color = strength_to_color(point.strength);
426
427        if self.config.show_temperature && point.temperature > 0.1 {
428            let temp_color = temperature_to_color(point.temperature);
429            let t = (point.temperature * 0.5).min(0.7);
430            color = Vec4::new(
431                color.x * (1.0 - t) + temp_color.x * t,
432                color.y * (1.0 - t) + temp_color.y * t,
433                color.z * (1.0 - t) + temp_color.z * t,
434                color.w.max(temp_color.w),
435            );
436        }
437
438        if self.config.show_entropy && point.entropy > 0.1 {
439            let entropy_color = entropy_to_color(point.entropy);
440            let t = (point.entropy * 0.5).min(0.6);
441            color = Vec4::new(
442                color.x * (1.0 - t) + entropy_color.x * t,
443                color.y * (1.0 - t) + entropy_color.y * t,
444                color.z * (1.0 - t) + entropy_color.z * t,
445                color.w.max(entropy_color.w),
446            );
447        }
448
449        color
450    }
451
452    /// Compute arrow scale from strength.
453    fn compute_point_scale(&self, point: &SamplePoint) -> f32 {
454        let base = 0.4 + point.strength.min(2.0) * 0.5;
455        base.min(self.config.max_arrow_scale)
456    }
457
458    // ── Shockwave management ────────────────────────────────────────────
459
460    fn tick_shockwaves(&mut self, dt: f32) {
461        self.shockwave_rings.retain_mut(|ring| ring.tick(dt));
462    }
463
464    // ── Boss overlay management ─────────────────────────────────────────
465
466    fn tick_boss_overlays(&mut self, dt: f32) {
467        for overlay in &mut self.boss_overlays {
468            overlay.tick(dt);
469        }
470        self.boss_overlays.retain(|o| o.active);
471    }
472
473    // ── Public API for adding effects ───────────────────────────────────
474
475    /// Add an expanding shockwave ring.
476    pub fn add_shockwave(&mut self, center: Vec2, speed: f32, max_radius: f32, strength: f32) {
477        self.shockwave_rings.push(ShockwaveRing {
478            center,
479            radius: 0.0,
480            speed,
481            max_radius,
482            strength,
483            ring_width: 2.0,
484        });
485    }
486
487    /// Add a gravity well visualization (concentric inward rings).
488    pub fn add_gravity_well(&mut self, center: Vec2, radius: f32, ring_count: u32) {
489        self.gravity_wells.push(GravityWellViz {
490            center,
491            radius,
492            ring_count,
493            pulse_phase: 0.0,
494        });
495    }
496
497    /// Remove all gravity wells.
498    pub fn clear_gravity_wells(&mut self) {
499        self.gravity_wells.clear();
500    }
501
502    /// Add a boss-specific overlay.
503    pub fn add_boss_overlay(&mut self, overlay: BossFieldOverlay) {
504        self.boss_overlays.push(overlay);
505    }
506
507    /// Remove all boss overlays.
508    pub fn clear_boss_overlays(&mut self) {
509        self.boss_overlays.clear();
510    }
511
512    /// Get the total number of visible arrow glyphs.
513    pub fn visible_count(&self) -> usize {
514        self.sample_points.iter().filter(|p| p.glyph_id.is_some()).count()
515    }
516
517    /// Get average field strength across the grid.
518    pub fn avg_strength(&self) -> f32 {
519        let sum: f32 = self.sample_points.iter().map(|p| p.strength).sum();
520        sum / self.sample_points.len().max(1) as f32
521    }
522
523    /// Get maximum field strength on the grid.
524    pub fn max_strength(&self) -> f32 {
525        self.sample_points.iter().map(|p| p.strength).fold(0.0f32, f32::max)
526    }
527}
528
529/// Compute point color from config (free function to avoid borrow conflicts).
530fn compute_point_color_static(config: &FieldVizConfig, point: &SamplePoint) -> Vec4 {
531    let mut color = strength_to_color(point.strength);
532    if config.show_temperature && point.temperature > 0.1 {
533        let temp_color = temperature_to_color(point.temperature);
534        let t = (point.temperature * 0.5).min(0.7);
535        color = Vec4::new(
536            color.x * (1.0 - t) + temp_color.x * t,
537            color.y * (1.0 - t) + temp_color.y * t,
538            color.z * (1.0 - t) + temp_color.z * t,
539            color.w.max(temp_color.w),
540        );
541    }
542    if config.show_entropy && point.entropy > 0.1 {
543        let entropy_color = entropy_to_color(point.entropy);
544        let t = (point.entropy * 0.5).min(0.6);
545        color = Vec4::new(
546            color.x * (1.0 - t) + entropy_color.x * t,
547            color.y * (1.0 - t) + entropy_color.y * t,
548            color.z * (1.0 - t) + entropy_color.z * t,
549            color.w.max(entropy_color.w),
550        );
551    }
552    color
553}
554
555/// Compute point scale from config (free function to avoid borrow conflicts).
556fn compute_point_scale_static(config: &FieldVizConfig, point: &SamplePoint) -> f32 {
557    let base = 0.4 + point.strength.min(2.0) * 0.5;
558    base.min(config.max_arrow_scale)
559}
560
561// ── Shockwave Ring ──────────────────────────────────────────────────────────
562
563/// An expanding ring of outward-pointing arrows that passes through the grid.
564#[derive(Clone, Debug)]
565struct ShockwaveRing {
566    center: Vec2,
567    radius: f32,
568    speed: f32,
569    max_radius: f32,
570    strength: f32,
571    ring_width: f32,
572}
573
574impl ShockwaveRing {
575    /// Advance the ring. Returns false when expired.
576    fn tick(&mut self, dt: f32) -> bool {
577        self.radius += self.speed * dt;
578        // Fade strength as it expands.
579        let frac = self.radius / self.max_radius;
580        self.strength *= 1.0 - frac * dt * 2.0;
581        self.radius < self.max_radius && self.strength > 0.01
582    }
583
584    /// Modify a sample point if it falls within the ring band.
585    fn modify_sample(&self, point: &mut SamplePoint) {
586        let to_point = point.world_pos - self.center;
587        let dist = to_point.length();
588        let ring_dist = (dist - self.radius).abs();
589
590        if ring_dist < self.ring_width && dist > 0.01 {
591            let ring_factor = 1.0 - ring_dist / self.ring_width;
592            let outward = to_point / dist;
593            // Add outward force to existing direction.
594            point.direction = (point.direction + outward * ring_factor * 2.0).normalize_or_zero();
595            point.strength += self.strength * ring_factor;
596        }
597    }
598}
599
600// ── Gravity Well Visualization ──────────────────────────────────────────────
601
602/// Concentric rings of inward-pointing arrows around a gravity well.
603#[derive(Clone, Debug)]
604struct GravityWellViz {
605    center: Vec2,
606    radius: f32,
607    ring_count: u32,
608    pulse_phase: f32,
609}
610
611impl GravityWellViz {
612    /// Check if a point falls on one of the concentric rings.
613    fn is_on_ring(&self, pos: Vec2, time: f32) -> Option<f32> {
614        let dist = (pos - self.center).length();
615        if dist > self.radius || dist < 0.1 {
616            return None;
617        }
618
619        let ring_spacing = self.radius / self.ring_count as f32;
620        // Animate rings inward.
621        let offset = (time * 2.0 + self.pulse_phase) % ring_spacing;
622
623        for i in 0..self.ring_count {
624            let ring_r = ring_spacing * i as f32 + offset;
625            let ring_dist = (dist - ring_r).abs();
626            if ring_dist < ring_spacing * 0.2 {
627                let intensity = 1.0 - ring_dist / (ring_spacing * 0.2);
628                return Some(intensity);
629            }
630        }
631        None
632    }
633}
634
635// ── Boss Field Overlays ─────────────────────────────────────────────────────
636
637/// Boss-specific field visualization overlay.
638#[derive(Clone, Debug)]
639pub struct BossFieldOverlay {
640    pub boss_type: BossOverlayType,
641    pub center: Vec2,
642    pub radius: f32,
643    pub intensity: f32,
644    pub active: bool,
645    pub age: f32,
646}
647
648/// The type of boss overlay, determining visual behavior.
649#[derive(Clone, Debug, PartialEq)]
650pub enum BossOverlayType {
651    /// The Algorithm Reborn: adaptation field shown as distorted/jittering arrows.
652    AlgorithmAdaptation,
653    /// The Null: void field shown as arrows being consumed (fade to nothing).
654    NullVoid,
655    /// The Ouroboros: healing flow shown as golden arrows circling.
656    OuroborosHealing {
657        /// Angle of the damage zone (radians).
658        damage_angle: f32,
659    },
660    /// Generic attractor: warps arrows toward a center point.
661    Attractor,
662    /// Generic repulsor: pushes arrows outward.
663    Repulsor,
664}
665
666impl BossFieldOverlay {
667    pub fn algorithm(center: Vec2, radius: f32) -> Self {
668        Self {
669            boss_type: BossOverlayType::AlgorithmAdaptation,
670            center, radius,
671            intensity: 1.0,
672            active: true,
673            age: 0.0,
674        }
675    }
676
677    pub fn null_void(center: Vec2, radius: f32) -> Self {
678        Self {
679            boss_type: BossOverlayType::NullVoid,
680            center, radius,
681            intensity: 1.0,
682            active: true,
683            age: 0.0,
684        }
685    }
686
687    pub fn ouroboros(center: Vec2, radius: f32, damage_angle: f32) -> Self {
688        Self {
689            boss_type: BossOverlayType::OuroborosHealing { damage_angle },
690            center, radius,
691            intensity: 1.0,
692            active: true,
693            age: 0.0,
694        }
695    }
696
697    pub fn attractor(center: Vec2, radius: f32) -> Self {
698        Self {
699            boss_type: BossOverlayType::Attractor,
700            center, radius,
701            intensity: 1.0,
702            active: true,
703            age: 0.0,
704        }
705    }
706
707    pub fn repulsor(center: Vec2, radius: f32) -> Self {
708        Self {
709            boss_type: BossOverlayType::Repulsor,
710            center, radius,
711            intensity: 1.0,
712            active: true,
713            age: 0.0,
714        }
715    }
716
717    fn tick(&mut self, dt: f32) {
718        self.age += dt;
719    }
720
721    /// Modify a sample point based on this boss overlay.
722    fn modify_sample(&self, point: &mut SamplePoint) {
723        let to_point = point.world_pos - self.center;
724        let dist = to_point.length();
725
726        if dist > self.radius {
727            return;
728        }
729
730        let influence = 1.0 - (dist / self.radius);
731
732        match &self.boss_type {
733            BossOverlayType::AlgorithmAdaptation => {
734                // Distortion: jitter the direction based on time.
735                let jitter_x = (self.age * 7.0 + point.world_pos.x * 3.0).sin() * 0.3;
736                let jitter_y = (self.age * 5.0 + point.world_pos.y * 4.0).cos() * 0.3;
737                let jitter = Vec2::new(jitter_x, jitter_y) * influence * self.intensity;
738                point.direction = (point.direction + jitter).normalize_or_zero();
739                // Tint toward cyan/purple.
740                point.strength += influence * 0.3 * self.intensity;
741            }
742
743            BossOverlayType::NullVoid => {
744                // Consume: reduce strength near the center, pulling arrows inward.
745                let void_factor = influence * influence * self.intensity;
746                point.strength *= 1.0 - void_factor * 0.9;
747                // Pull direction inward (toward the void).
748                if dist > 0.01 {
749                    let inward = -to_point / dist;
750                    point.direction = Vec2::lerp(point.direction, inward, void_factor * 0.7);
751                    point.direction = point.direction.normalize_or_zero();
752                }
753                // Darken: reduce existing color toward black (applied via entropy).
754                point.entropy += void_factor * 0.5;
755            }
756
757            BossOverlayType::OuroborosHealing { damage_angle } => {
758                // Healing flow: golden arrows circling from damage zone to boss.
759                if dist > 0.5 && dist < self.radius {
760                    let angle_to_point = to_point.y.atan2(to_point.x);
761                    // Tangential direction (circling).
762                    let tangent = Vec2::new(-to_point.y, to_point.x).normalize_or_zero();
763                    // Determine which direction to circle based on damage angle.
764                    let angle_diff = (angle_to_point - damage_angle + std::f32::consts::PI) %
765                        (2.0 * std::f32::consts::PI) - std::f32::consts::PI;
766                    let circle_dir = if angle_diff > 0.0 { tangent } else { -tangent };
767                    // Also pull slightly inward.
768                    let inward = -to_point.normalize_or_zero() * 0.3;
769                    let healing_dir = (circle_dir + inward).normalize_or_zero();
770
771                    point.direction = Vec2::lerp(point.direction, healing_dir, influence * 0.6);
772                    point.direction = point.direction.normalize_or_zero();
773                    // Golden tint: increase temperature to trigger warm colors.
774                    point.temperature += influence * 0.8 * self.intensity;
775                    point.strength += influence * 0.2;
776                }
777            }
778
779            BossOverlayType::Attractor => {
780                // Pull arrows inward.
781                if dist > 0.01 {
782                    let inward = -to_point / dist;
783                    let pull = influence * self.intensity * 0.8;
784                    point.direction = Vec2::lerp(point.direction, inward, pull);
785                    point.direction = point.direction.normalize_or_zero();
786                    point.strength += influence * 0.5 * self.intensity;
787                }
788            }
789
790            BossOverlayType::Repulsor => {
791                // Push arrows outward.
792                if dist > 0.01 {
793                    let outward = to_point / dist;
794                    let push = influence * self.intensity * 0.8;
795                    point.direction = Vec2::lerp(point.direction, outward, push);
796                    point.direction = point.direction.normalize_or_zero();
797                    point.strength += influence * 0.4 * self.intensity;
798                }
799            }
800        }
801    }
802}
803
804// ── Streamline Tracer ───────────────────────────────────────────────────────
805
806/// Trace a streamline through the force field from a starting point.
807///
808/// Returns a series of positions that can be rendered as a connected line
809/// (using box-drawing characters or dash glyphs).
810pub fn trace_streamline(
811    field_mgr: &FieldManager,
812    start: Vec2,
813    time: f32,
814    max_steps: usize,
815    step_size: f32,
816) -> Vec<Vec2> {
817    let mut positions = Vec::with_capacity(max_steps);
818    let mut pos = start;
819
820    for _ in 0..max_steps {
821        positions.push(pos);
822        let pos3 = Vec3::new(pos.x, pos.y, 0.0);
823        let sample = field_mgr.sample(pos3, 1.0, 0.0, time);
824        let force = Vec2::new(sample.force.x, sample.force.y);
825        if force.length_squared() < 0.0001 {
826            break;
827        }
828        pos += force.normalize() * step_size;
829    }
830
831    positions
832}
833
834/// Spawn streamline glyphs using dash characters along the path.
835pub fn spawn_streamline_glyphs(
836    pool: &mut GlyphPool,
837    positions: &[Vec2],
838    color: Vec4,
839    z_depth: f32,
840) -> Vec<GlyphId> {
841    let mut ids = Vec::with_capacity(positions.len());
842    for (i, pos) in positions.iter().enumerate() {
843        if i + 1 >= positions.len() {
844            break;
845        }
846        let next = positions[i + 1];
847        let dir = next - *pos;
848        let arrow = direction_to_arrow(dir);
849        let fade = 1.0 - (i as f32 / positions.len() as f32);
850
851        let glyph = Glyph {
852            character: arrow,
853            position: Vec3::new(pos.x, pos.y, z_depth),
854            color: Vec4::new(color.x, color.y, color.z, color.w * fade),
855            scale: Vec2::splat(0.5 + fade * 0.5),
856            emission: 0.2 * fade,
857            glow_color: Vec3::new(color.x, color.y, color.z),
858            glow_radius: 0.3 * fade,
859            layer: RenderLayer::Overlay,
860            blend_mode: BlendMode::Additive,
861            visible: true,
862            lifetime: 0.5,
863            ..Glyph::default()
864        };
865        ids.push(pool.spawn(glyph));
866    }
867    ids
868}
869
870// ── Field Snapshot ──────────────────────────────────────────────────────────
871
872/// A snapshot of the visualization grid that can be used for analysis or export.
873#[derive(Clone, Debug)]
874pub struct FieldSnapshot {
875    pub cols: u32,
876    pub rows: u32,
877    pub directions: Vec<Vec2>,
878    pub strengths: Vec<f32>,
879    pub temperatures: Vec<f32>,
880    pub entropies: Vec<f32>,
881}
882
883impl FieldVisualizer {
884    /// Take a snapshot of the current grid state.
885    pub fn snapshot(&self) -> FieldSnapshot {
886        FieldSnapshot {
887            cols: self.config.cols,
888            rows: self.config.rows,
889            directions: self.sample_points.iter().map(|p| p.direction).collect(),
890            strengths: self.sample_points.iter().map(|p| p.strength).collect(),
891            temperatures: self.sample_points.iter().map(|p| p.temperature).collect(),
892            entropies: self.sample_points.iter().map(|p| p.entropy).collect(),
893        }
894    }
895}
896
897// ── Tests ───────────────────────────────────────────────────────────────────
898
899#[cfg(test)]
900mod tests {
901    use super::*;
902
903    #[test]
904    fn direction_to_arrow_right() {
905        assert_eq!(direction_to_arrow(Vec2::new(1.0, 0.0)), '→');
906    }
907
908    #[test]
909    fn direction_to_arrow_up() {
910        assert_eq!(direction_to_arrow(Vec2::new(0.0, 1.0)), '↑');
911    }
912
913    #[test]
914    fn direction_to_arrow_zero() {
915        assert_eq!(direction_to_arrow(Vec2::ZERO), '·');
916    }
917
918    #[test]
919    fn strength_color_gradient() {
920        let weak = strength_to_color(0.1);
921        let strong = strength_to_color(2.0);
922        // Weak should be blueish, strong should be reddish.
923        assert!(weak.z > weak.x, "Weak should be blue-dominant");
924        assert!(strong.x > strong.z, "Strong should be red-dominant");
925    }
926
927    #[test]
928    fn shockwave_ring_expires() {
929        let mut ring = ShockwaveRing {
930            center: Vec2::ZERO,
931            radius: 0.0,
932            speed: 10.0,
933            max_radius: 5.0,
934            strength: 1.0,
935            ring_width: 2.0,
936        };
937        assert!(ring.tick(0.1)); // still alive
938        for _ in 0..100 {
939            ring.tick(0.1);
940        }
941        assert!(!ring.tick(0.1)); // expired
942    }
943
944    #[test]
945    fn boss_overlay_null_void_reduces_strength() {
946        let overlay = BossFieldOverlay::null_void(Vec2::ZERO, 10.0);
947        let mut point = SamplePoint {
948            world_pos: Vec2::new(1.0, 0.0),
949            direction: Vec2::new(1.0, 0.0),
950            strength: 1.0,
951            temperature: 0.0,
952            entropy: 0.0,
953            field_count: 1,
954            glyph_id: None,
955        };
956        overlay.modify_sample(&mut point);
957        assert!(point.strength < 1.0, "Void should reduce strength");
958    }
959
960    #[test]
961    fn boss_overlay_ouroboros_adds_tangential() {
962        let overlay = BossFieldOverlay::ouroboros(Vec2::ZERO, 10.0, 0.0);
963        let mut point = SamplePoint {
964            world_pos: Vec2::new(5.0, 0.0),
965            direction: Vec2::new(1.0, 0.0),
966            strength: 0.5,
967            temperature: 0.0,
968            entropy: 0.0,
969            field_count: 1,
970            glyph_id: None,
971        };
972        overlay.modify_sample(&mut point);
973        // Should have Y component from tangential flow.
974        assert!(point.direction.y.abs() > 0.01, "Should have tangential component");
975    }
976
977    #[test]
978    fn field_viz_creation() {
979        let viz = FieldVisualizer::new(Vec2::ZERO, FieldVizConfig::default());
980        assert_eq!(viz.sample_points.len(), (40 * 25) as usize);
981        assert!(!viz.active);
982    }
983
984    #[test]
985    fn field_viz_recenter() {
986        let mut viz = FieldVisualizer::new(Vec2::ZERO, FieldVizConfig {
987            cols: 3, rows: 3, spacing: 1.0, ..FieldVizConfig::default()
988        });
989        viz.set_center(Vec2::new(10.0, 10.0));
990        // Center point should be near (10, 10).
991        let mid = &viz.sample_points[4]; // 3x3 center = index 4
992        assert!((mid.world_pos.x - 10.0).abs() < 1.5);
993    }
994
995    #[test]
996    fn snapshot_sizes_match() {
997        let viz = FieldVisualizer::new(Vec2::ZERO, FieldVizConfig {
998            cols: 5, rows: 5, ..FieldVizConfig::default()
999        });
1000        let snap = viz.snapshot();
1001        assert_eq!(snap.directions.len(), 25);
1002        assert_eq!(snap.strengths.len(), 25);
1003    }
1004}