1use std::time::Duration;
2
3use rand::{rngs::StdRng, Rng, SeedableRng};
4use ratatui::{
5 layout::Rect,
6 style::Style,
7 Frame,
8};
9
10use crate::theme::aura_color;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum BreathPhase {
15 Inhale,
16 Exhale,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum AuraGlyphMode {
22 Braille,
23 Taz,
24 Math,
25 Mahjong,
26 Dominoes,
27 Cards,
28}
29
30#[derive(Debug, Clone)]
32pub struct Ripple {
33 pub t0: f32,
34 pub speed: f32,
35 pub width: f32,
36 pub strength: f32,
37 pub center: Option<(f32, f32)>,
38 pub start_radius: f32,
39 pub direction: RippleDir,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum RippleDir {
45 Outward,
46 Inward,
47}
48
49pub struct Aura {
51 frame: u64,
52 time_s: f32,
53 phase: BreathPhase,
54 breath_t: f32,
55 breath_duration: f32,
56 pub ripples: Vec<Ripple>,
57 rng: StdRng,
58 glyph_mode: AuraGlyphMode,
59 braille_by_dots: [Vec<u8>; 9],
60}
61
62impl Aura {
63 pub fn new() -> Self {
64 let mut braille_by_dots = std::array::from_fn(|_| Vec::new());
65 for pattern in 0u16..=255 {
66 let dots = (pattern as u8).count_ones() as usize;
67 braille_by_dots[dots].push(pattern as u8);
68 }
69 Aura {
70 frame: 0,
71 time_s: 0.0,
72 phase: BreathPhase::Inhale,
73 breath_t: 0.0,
74 breath_duration: 60.0 / 7.0,
75 ripples: Vec::new(),
76 rng: StdRng::from_entropy(),
77 glyph_mode: AuraGlyphMode::Braille,
78 braille_by_dots,
79 }
80 }
81
82 pub fn set_glyph_mode(&mut self, mode: AuraGlyphMode) {
83 self.glyph_mode = mode;
84 }
85
86 pub fn glyph_mode(&self) -> AuraGlyphMode {
87 self.glyph_mode
88 }
89
90 pub fn tick(&mut self, dt: Duration) {
92 self.frame = self.frame.wrapping_add(1);
93 let dt_s = dt.as_secs_f32();
94 self.time_s += dt_s;
95
96 self.breath_t += dt_s / self.breath_duration;
97 if self.breath_t >= 1.0 {
98 self.breath_t -= 1.0;
99 self.phase = match self.phase {
100 BreathPhase::Inhale => BreathPhase::Exhale,
101 BreathPhase::Exhale => BreathPhase::Inhale,
102 };
103 }
104
105 let max_age = self.breath_duration * 3.0;
106 self.ripples.retain(|r| self.time_s - r.t0 <= max_age);
107 }
108
109 pub fn launch_ripples(&mut self, base_strength: f32, pace: f32) {
111 let now = self.time_s;
112 for i in 0..3 {
113 let jitter = (i as f32) * 0.1;
114 let strength = (base_strength * (0.8 + 0.4 * self.rng.r#gen::<f32>())).clamp(0.0, 1.0);
115 let speed = (0.35 + 0.2 * self.rng.r#gen::<f32>()) * pace.clamp(0.6, 2.4);
116 let width = 0.1 + 0.05 * self.rng.r#gen::<f32>();
117 self.ripples.push(Ripple {
118 t0: now + jitter,
119 speed,
120 width,
121 strength,
122 center: None,
123 start_radius: 1.0,
124 direction: RippleDir::Outward,
125 });
126 }
127 }
128
129 pub fn launch_ripple_at(&mut self, x: u16, y: u16, area: Rect, pace: f32) {
131 let (hx, hy, cx, cy) = hole_geometry(area);
132 let dx = x as f32 - cx;
133 let dy = y as f32 - cy;
134 let nx = dx / hx;
135 let ny = dy / hy;
136 let e = (nx * nx + ny * ny).sqrt();
137 if e < 1.0 {
138 return;
139 }
140
141 for i in 0..3 {
142 let jitter = (i as f32) * 0.06;
143 let strength = 0.75 + 0.25 * self.rng.r#gen::<f32>();
144 let speed = (0.55 + 0.35 * self.rng.r#gen::<f32>()) * pace.clamp(0.6, 2.4);
145 let width = 0.10 + 0.06 * self.rng.r#gen::<f32>();
146 self.ripples.push(Ripple {
147 t0: self.time_s + jitter,
148 speed,
149 width,
150 strength,
151 center: Some((nx, ny)),
152 start_radius: 0.0,
153 direction: RippleDir::Outward,
154 });
155 }
156 }
157
158 pub fn launch_inward_ripple(&mut self) {
162 self.ripples.push(Ripple {
163 t0: self.time_s,
164 speed: 0.25,
165 width: 0.12,
166 strength: 0.4,
167 center: None,
168 start_radius: 1.6,
169 direction: RippleDir::Inward,
170 });
171 }
172
173 pub fn render(&mut self, frame: &mut Frame, area: Rect, voice_intensity: f32) {
175 let width = area.width as usize;
176 let height = area.height as usize;
177
178 if width == 0 || height == 0 {
179 return;
180 }
181
182 let breath_env = self.breath_envelope();
183
184 let cx = area.x as f32 + (area.width as f32 / 2.0);
185 let cy = area.y as f32 + (area.height as f32 / 2.0);
186 let max_dist = ((area.width as f32).hypot(area.height as f32)) / 2.0;
187 let (hx, hy, _, _) = hole_geometry(area);
188
189 let buf = frame.buffer_mut();
190
191 for row in 0..height {
192 for col in 0..width {
193 let x = area.x as u16 + col as u16;
194 let y = area.y as u16 + row as u16;
195
196 let dx = x as f32 - cx;
197 let dy = y as f32 - cy;
198 let dist = (dx * dx + dy * dy).sqrt();
199 let _r = (dist / max_dist).clamp(0.0, 1.0);
200
201 let nx = dx / hx;
202 let ny = dy / hy;
203 let e = (nx * nx + ny * ny).sqrt();
204
205 let ring_start: f32 = 1.0;
206 let ring_width: f32 = 0.35;
207 let ring_end = ring_start + ring_width;
208
209 let noise = self.noise3(col as u32, row as u32, self.frame / 2) * 0.06;
210 let shimmer = self.noise3(col as u32, row as u32, self.frame / 5);
211
212 if e < ring_start {
213 let cell = buf.get_mut(x, y);
214 cell.set_symbol(" ");
216 cell.set_style(Style::default());
217
218 let mist = (noise * 0.7 + breath_env * 0.03 + (shimmer - 0.5) * 0.05).clamp(0.0, 1.0);
220 if mist < 0.18 {
221 continue;
222 }
223 let (ch, _) = self.glyph_for_energy_stochastic(mist, noise, col as u32, row as u32);
224 let mut symbol_buf = [0u8; 4];
225 let symbol = ch.encode_utf8(&mut symbol_buf);
226 cell.set_symbol(symbol);
227 let color = aura_color(mist, noise, shimmer);
228 cell.set_style(Style::default().fg(color));
229 continue;
230 }
231
232 let ring_t = ((e - ring_start) / (ring_end - ring_start)).clamp(0.0, 1.0);
233 let ring_env = smoothstep(0.0, 1.0, ring_t);
234
235 let base_energy = match self.phase {
236 BreathPhase::Inhale => breath_env * (1.0 - ring_t),
237 BreathPhase::Exhale => breath_env * ring_t,
238 };
239
240 let ripple_energy = self.ripple_energy(e, nx, ny, shimmer, noise);
241
242 let ripple_mod = 0.45 + 0.35 * shimmer;
243 let mut energy = (base_energy * 0.55 + ripple_energy * ripple_mod + noise + 0.05) * ring_env;
244 energy *= 0.55 + 0.35 * voice_intensity;
245 let mut energy = energy.clamp(0.0, 1.0);
246
247 let jitter = (self.noise3(col as u32, row as u32, self.frame.wrapping_add(17) / 3) - 0.5) * 0.14;
248 energy = (energy + jitter).clamp(0.0, 1.0);
249
250 let blank_gate = self.noise3(col as u32, row as u32, self.frame / 4);
251 let blank_thresh = if ripple_energy > 0.04 { 0.46 } else { 0.36 };
252 if blank_gate < blank_thresh && energy < 0.7 {
253 continue;
254 }
255
256 let (ch, _tier) = self.glyph_for_energy_stochastic(energy, noise, col as u32, row as u32);
257 let cell = buf.get_mut(x, y);
258 let mut symbol_buf = [0u8; 4];
259 let symbol = ch.encode_utf8(&mut symbol_buf);
260 cell.set_symbol(symbol);
261 let color = aura_color(energy, noise, shimmer);
262 cell.set_style(Style::default().fg(color));
263 }
264 }
265 }
266
267 fn breath_envelope(&self) -> f32 {
268 let s = (std::f32::consts::PI * self.breath_t).sin().max(0.0);
269 match self.phase {
270 BreathPhase::Inhale => s,
271 BreathPhase::Exhale => s,
272 }
273 }
274
275 fn ripple_energy(&self, r: f32, nx: f32, ny: f32, shimmer: f32, noise: f32) -> f32 {
276 let mut acc = 0.0;
277 for ripple in &self.ripples {
278 let age = self.time_s - ripple.t0;
279 if age < 0.0 {
280 continue;
281 }
282 let wobble = (shimmer - 0.5) * 0.08 + (noise - 0.5) * 0.06;
283 let center_r = match ripple.direction {
284 RippleDir::Outward => ripple.start_radius + ripple.speed * age * (1.0 + wobble),
285 RippleDir::Inward => (ripple.start_radius - ripple.speed * age).max(0.0),
286 };
287 let dist = if let Some((cx, cy)) = ripple.center {
288 let dx = nx - cx;
289 let dy = ny - cy;
290 (dx * dx + dy * dy).sqrt()
291 } else {
292 r
293 };
294 let dr = (dist - center_r).abs();
295 let ring_env = (1.0 - (dr / ripple.width)).clamp(0.0, 1.0);
296 let max_age = self.breath_duration * 3.0;
297 let time_env = (1.0 - age / max_age).clamp(0.0, 1.0);
298 acc += ripple.strength * ring_env * time_env;
299 }
300 acc
301 }
302
303 fn noise3(&self, x: u32, y: u32, z: u64) -> f32 {
304 let mut h = x.wrapping_mul(374761393)
305 ^ y.wrapping_mul(668265263)
306 ^ (z as u32).wrapping_mul(2246822519);
307 h = (h ^ (h >> 13)).wrapping_mul(1274126177);
308 let v = (h ^ (h >> 16)) & 0xffff;
309 (v as f32) / 65535.0
310 }
311
312 fn glyph_for_energy_stochastic(&self, e: f32, _noise: f32, col: u32, row: u32) -> (char, u8) {
313 const ENERGY_FLOOR: f32 = 0.12;
314 const TIERS: usize = 4;
315
316 if e < ENERGY_FLOOR {
317 return (' ', 0);
318 }
319
320 let t = ((e - ENERGY_FLOOR) / (1.0 - ENERGY_FLOOR)).clamp(0.0, 1.0);
321 let tier = (t * TIERS as f32).floor().min((TIERS - 1) as f32) as usize;
322
323 match self.glyph_mode {
324 AuraGlyphMode::Braille => {
325 let t_skew = t.powf(2.2);
326 let mut dots = 1 + (t_skew * 7.999).floor() as u8;
327 let roll = self.noise3(col, row, self.frame.wrapping_add(100));
329 if roll < 0.28 {
330 dots = dots.saturating_sub(1);
331 }
332 if roll < 0.12 {
333 dots = dots.saturating_sub(1);
334 }
335 if dots == 0 {
336 return (' ', 0);
337 }
338 let list = &self.braille_by_dots[dots as usize];
339 let idx_noise = self.noise3(col, row, self.frame.wrapping_add(200));
340 let idx = (idx_noise * list.len() as f32) as usize % list.len().max(1);
341 let pattern = list[idx];
342 (braille_char(pattern), dots)
343 }
344 AuraGlyphMode::Taz => {
345 let ch = sample_tier(self.noise3(col, row, self.frame.wrapping_add(300)), TAZ_TIERS[tier]);
346 (ch, (tier + 1) as u8)
347 }
348 AuraGlyphMode::Math => {
349 let ch = sample_tier(self.noise3(col, row, self.frame.wrapping_add(300)), MATH_TIERS[tier]);
350 (ch, (tier + 1) as u8)
351 }
352 AuraGlyphMode::Mahjong => {
353 let ch = sample_tier(self.noise3(col, row, self.frame.wrapping_add(300)), MAHJONG_TIERS[tier]);
354 (ch, (tier + 1) as u8)
355 }
356 AuraGlyphMode::Dominoes => {
357 let ch = sample_tier(self.noise3(col, row, self.frame.wrapping_add(300)), DOMINOES_TIERS[tier]);
358 (ch, (tier + 1) as u8)
359 }
360 AuraGlyphMode::Cards => {
361 let ch = sample_tier(self.noise3(col, row, self.frame.wrapping_add(300)), CARDS_TIERS[tier]);
362 (ch, (tier + 1) as u8)
363 }
364 }
365 }
366}
367
368fn braille_char(pattern: u8) -> char {
369 char::from_u32(0x2800 + pattern as u32).unwrap_or(' ')
370}
371
372fn sample_tier(noise: f32, tier: &[char]) -> char {
373 if tier.is_empty() {
374 return ' ';
375 }
376 let idx = (noise * tier.len() as f32) as usize % tier.len();
377 tier[idx]
378}
379
380const TAZ_TIERS: [&[char]; 4] = [
381 &['.', ',', ':', '\''],
382 &['!', '?', ';', '~', '^'],
383 &['@', '#', '$', '%', '&'],
384 &['*', '¶', '§', '†', '‽', '∅'],
385];
386
387const MATH_TIERS: [&[char]; 4] = [
388 &['+', '-', '=', '·', '×', '÷'],
389 &['±', '≈', '≠', '≤', '≥', '∝', '√'],
390 &['∑', '∏', '∫', '∂', '∞', '∇'],
391 &['∮', '∴', '∵', '∃', '∀', '∘', '⊕', '⊗'],
392];
393
394const MAHJONG_TIERS: [&[char]; 4] = [
395 &['🀇', '🀈', '🀉', '🀊', '🀋', '🀌', '🀍', '🀎', '🀏'],
396 &['🀐', '🀑', '🀒', '🀓', '🀔', '🀕', '🀖', '🀗', '🀘'],
397 &['🀀', '🀁', '🀂', '🀃', '🀄', '🀅', '🀆'],
398 &['🀙', '🀚', '🀛', '🀜', '🀝', '🀞', '🀟', '🀠', '🀡', '🀢', '🀣', '🀤', '🀥', '🀦', '🀧', '🀨', '🀩', '🀪', '🀫'],
399];
400
401const DOMINOES_TIERS: [&[char]; 4] = [
402 &['🀰', '🀱', '🀲', '🀳', '🀴', '🀵', '🀶', '🀷'],
403 &['🀸', '🀹', '🀺', '🀻', '🀼', '🀽', '🀾', '🀿'],
404 &['🁀', '🁁', '🁂', '🁃', '🁄', '🁅', '🁆', '🁇'],
405 &['🁈', '🁉', '🁊', '🁋', '🁌', '🁍', '🁎', '🁏'],
406];
407
408const CARDS_TIERS: [&[char]; 4] = [
409 &['🂡', '🂢', '🂣', '🂤', '🂥', '🂦', '🂧', '🂨', '🂩'],
410 &['🂱', '🂲', '🂳', '🂴', '🂵', '🂶', '🂷', '🂸', '🂹'],
411 &['🃁', '🃂', '🃃', '🃄', '🃅', '🃆', '🃇', '🃈', '🃉'],
412 &['🃑', '🃒', '🃓', '🃔', '🃕', '🃖', '🃗', '🃘', '🃙'],
413];
414
415fn hole_geometry(area: Rect) -> (f32, f32, f32, f32) {
416 let target_half_w: f32 = 40.0;
417 let target_half_h: f32 = 12.0;
418 let half_w = (area.width as f32 / 2.0).max(1.0);
419 let half_h = (area.height as f32 / 2.0).max(1.0);
420 let hx = target_half_w.min(half_w - 1.0).max(1.0);
421 let hy = target_half_h.min(half_h - 1.0).max(1.0);
422 let cx = area.x as f32 + (area.width as f32 / 2.0);
423 let cy = area.y as f32 + (area.height as f32 / 2.0);
424 (hx, hy, cx, cy)
425}
426
427fn smoothstep(edge0: f32, edge1: f32, x: f32) -> f32 {
428 let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
429 t * t * (3.0 - 2.0 * t)
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435 use ratatui::layout::Rect;
436
437 #[test]
438 fn hole_geometry_is_deterministic() {
439 let area = Rect { x: 0, y: 0, width: 120, height: 40 };
440 let (hx1, hy1, cx1, cy1) = hole_geometry(area);
441 let (hx2, hy2, cx2, cy2) = hole_geometry(area);
442 assert_eq!(hx1, hx2);
443 assert_eq!(hy1, hy2);
444 assert_eq!(cx1, cx2);
445 assert_eq!(cy1, cy2);
446 }
447
448 #[test]
449 fn noise3_range() {
450 let aura = Aura::new();
451 for x in 0..10u32 {
452 for y in 0..10u32 {
453 for z in 0..10u64 {
454 let v = aura.noise3(x, y, z);
455 assert!(v >= 0.0 && v <= 1.0, "noise3({x},{y},{z}) = {v}");
456 }
457 }
458 }
459 }
460
461 #[test]
462 fn sample_tier_noise_bounds() {
463 let tier = &['a', 'b', 'c', 'd'];
464 assert_eq!(sample_tier(0.0, tier), 'a');
466 let idx = (0.999_f32 * tier.len() as f32) as usize % tier.len();
468 assert_eq!(sample_tier(0.999, tier), tier[idx]);
469 assert_eq!(sample_tier(0.5, &[]), ' ');
471 }
472
473 #[test]
474 fn inward_ripple_energy_at_center_r() {
475 let mut aura = Aura::new();
476 aura.launch_inward_ripple();
478 aura.tick(std::time::Duration::from_millis(400));
479 let energy = aura.ripple_energy(1.5, 0.0, 0.0, 0.5, 0.5);
481 assert!(energy > 0.3, "expected energy > 0.3 at center of inward ripple, got {energy}");
482 }
483
484 #[test]
485 fn inward_ripple_zero_after_contraction() {
486 let mut aura = Aura::new();
487 aura.launch_inward_ripple();
488 aura.tick(std::time::Duration::from_secs(7));
491 let energy = aura.ripple_energy(1.5, 0.0, 0.0, 0.5, 0.5);
492 assert!(energy < 0.001, "expected ~0 after inward ripple contracts, got {energy}");
493 }
494
495 #[test]
496 fn outward_ripples_all_have_outward_direction() {
497 let mut aura = Aura::new();
498 aura.launch_ripples(0.7, 1.0);
499 assert!(!aura.ripples.is_empty());
500 for r in &aura.ripples {
501 assert_eq!(r.direction, RippleDir::Outward);
502 }
503 let area = ratatui::layout::Rect { x: 0, y: 0, width: 120, height: 40 };
504 aura.launch_ripple_at(0, 0, area, 1.0); for r in &aura.ripples {
506 assert_eq!(r.direction, RippleDir::Outward);
507 }
508 }
509}