1use 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
34fn 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
56fn 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
72fn 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) } 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) } 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) } else {
91 let s = (t - 0.75) / 0.25;
92 Vec4::new(1.0, 1.0 - s * 0.7, 0.0, alpha) }
94}
95
96fn 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
102fn 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#[derive(Clone, Debug)]
112pub struct FieldVizConfig {
113 pub cols: u32,
115 pub rows: u32,
117 pub spacing: f32,
119 pub update_interval: f32,
121 pub strength_threshold: f32,
123 pub max_arrow_scale: f32,
125 pub z_depth: f32,
127 pub show_temperature: bool,
129 pub show_entropy: bool,
131 pub layer: RenderLayer,
133 pub blend: BlendMode,
135 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 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 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 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#[derive(Clone, Debug)]
203struct SamplePoint {
204 world_pos: Vec2,
206 direction: Vec2,
208 strength: f32,
210 temperature: f32,
212 entropy: f32,
214 field_count: usize,
216 glyph_id: Option<GlyphId>,
218}
219
220pub struct FieldVisualizer {
227 sample_points: Vec<SamplePoint>,
229 pub config: FieldVizConfig,
231 pub center: Vec2,
233 update_timer: f32,
235 pub active: bool,
237 pub boss_overlays: Vec<BossFieldOverlay>,
239 shockwave_rings: Vec<ShockwaveRing>,
241 gravity_wells: Vec<GravityWellViz>,
243 pub last_sample_us: u32,
245}
246
247impl FieldVisualizer {
248 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 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 pub fn toggle(&mut self) {
301 self.active = !self.active;
302 }
303
304 pub fn tick(&mut self, dt: f32, field_mgr: &FieldManager, time: f32) {
309 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 for overlay in &self.boss_overlays {
340 overlay.modify_sample(point);
341 }
342
343 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 pub fn update_glyphs(&mut self, pool: &mut GlyphPool) {
357 if !self.active {
358 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 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 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 if let Some(id) = point.glyph_id.take() {
408 pool.despawn(id);
409 }
410 }
411 }
412 }
413
414 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 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 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 fn tick_shockwaves(&mut self, dt: f32) {
461 self.shockwave_rings.retain_mut(|ring| ring.tick(dt));
462 }
463
464 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 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 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 pub fn clear_gravity_wells(&mut self) {
499 self.gravity_wells.clear();
500 }
501
502 pub fn add_boss_overlay(&mut self, overlay: BossFieldOverlay) {
504 self.boss_overlays.push(overlay);
505 }
506
507 pub fn clear_boss_overlays(&mut self) {
509 self.boss_overlays.clear();
510 }
511
512 pub fn visible_count(&self) -> usize {
514 self.sample_points.iter().filter(|p| p.glyph_id.is_some()).count()
515 }
516
517 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 pub fn max_strength(&self) -> f32 {
525 self.sample_points.iter().map(|p| p.strength).fold(0.0f32, f32::max)
526 }
527}
528
529fn 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
555fn 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#[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 fn tick(&mut self, dt: f32) -> bool {
577 self.radius += self.speed * dt;
578 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 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 point.direction = (point.direction + outward * ring_factor * 2.0).normalize_or_zero();
595 point.strength += self.strength * ring_factor;
596 }
597 }
598}
599
600#[derive(Clone, Debug)]
604struct GravityWellViz {
605 center: Vec2,
606 radius: f32,
607 ring_count: u32,
608 pulse_phase: f32,
609}
610
611impl GravityWellViz {
612 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 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#[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#[derive(Clone, Debug, PartialEq)]
650pub enum BossOverlayType {
651 AlgorithmAdaptation,
653 NullVoid,
655 OuroborosHealing {
657 damage_angle: f32,
659 },
660 Attractor,
662 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 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 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 point.strength += influence * 0.3 * self.intensity;
741 }
742
743 BossOverlayType::NullVoid => {
744 let void_factor = influence * influence * self.intensity;
746 point.strength *= 1.0 - void_factor * 0.9;
747 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 point.entropy += void_factor * 0.5;
755 }
756
757 BossOverlayType::OuroborosHealing { damage_angle } => {
758 if dist > 0.5 && dist < self.radius {
760 let angle_to_point = to_point.y.atan2(to_point.x);
761 let tangent = Vec2::new(-to_point.y, to_point.x).normalize_or_zero();
763 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 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 point.temperature += influence * 0.8 * self.intensity;
775 point.strength += influence * 0.2;
776 }
777 }
778
779 BossOverlayType::Attractor => {
780 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 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
804pub 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
834pub 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#[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 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#[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 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)); for _ in 0..100 {
939 ring.tick(0.1);
940 }
941 assert!(!ring.tick(0.1)); }
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 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 let mid = &viz.sample_points[4]; 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}