use crate::breath::Phase;
use crate::render::{Rgb, Surface};
const STAR_COUNT: usize = 160;
const NEAR_FRACTION: f32 = 0.3;
const MIN_FIELD_COLS: usize = 24;
const MIN_FIELD_CELL_ROWS: usize = 6;
const NEAR_BRIGHT: (f32, f32) = (0.55, 0.95);
const FAR_BRIGHT: (f32, f32) = (0.18, 0.45);
const NEAR_GLYPHS: [char; 3] = ['✦', '✧', '∗'];
const FAR_GLYPHS: [char; 3] = ['·', '⋆', '∙'];
const BLOOM_GAIN: f32 = 0.4;
const BLOOM_OFFSET: f32 = 1.5;
const STAR_COLOR: Rgb = Rgb::new(196, 214, 200);
struct NormStar {
nx: f32,
ny: f32,
glyph: char,
brightness: f32,
near: bool,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Star {
pub x: usize,
pub cell_y: usize,
pub glyph: char,
pub brightness: f32,
pub near: bool,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Bloom {
pub gain: f32,
pub offset: f32,
}
impl Bloom {
pub fn still() -> Bloom {
Bloom {
gain: 0.0,
offset: 0.0,
}
}
}
pub struct Starfield {
stars: Vec<NormStar>,
}
impl Starfield {
pub fn new(seed: u64) -> Starfield {
let mut state = seed ^ 0x5DEE_CE66_D000_0000;
let mut stars = Vec::with_capacity(STAR_COUNT);
for _ in 0..STAR_COUNT {
let nx = unit(&mut state);
let ny = unit(&mut state);
let near = unit(&mut state) < NEAR_FRACTION;
let (lo, hi) = if near { NEAR_BRIGHT } else { FAR_BRIGHT };
let glyphs = if near { NEAR_GLYPHS } else { FAR_GLYPHS };
let idx = ((unit(&mut state) * glyphs.len() as f32) as usize).min(glyphs.len() - 1);
let brightness = lo + unit(&mut state) * (hi - lo);
stars.push(NormStar {
nx,
ny,
glyph: glyphs[idx],
brightness,
near,
});
}
Starfield { stars }
}
pub fn cells(&self, width: usize, height: usize, clearing_radius: f32) -> Vec<Star> {
let cell_rows = height / 2;
if width < MIN_FIELD_COLS || cell_rows < MIN_FIELD_CELL_ROWS {
return Vec::new();
}
let ocx = width as f32 / 2.0;
let ocy = height as f32 / 2.0;
let mut out = Vec::new();
for s in &self.stars {
let x = ((s.nx * width as f32) as usize).min(width - 1);
let cell_y = ((s.ny * cell_rows as f32) as usize).min(cell_rows - 1);
let px = x as f32 + 0.5;
let py = (cell_y * 2) as f32 + 1.0;
let dist = ((px - ocx).powi(2) + (py - ocy).powi(2)).sqrt();
if dist < clearing_radius {
continue;
}
out.push(Star {
x,
cell_y,
glyph: s.glyph,
brightness: s.brightness,
near: s.near,
});
}
out
}
}
pub fn bloom(phase: Phase, progress: f32) -> Bloom {
let amount = match phase {
Phase::Exhale => progress.clamp(0.0, 1.0),
Phase::HoldOut => 1.0,
Phase::Inhale => 1.0 - progress.clamp(0.0, 1.0),
Phase::HoldIn | Phase::Still => 0.0,
};
Bloom {
gain: amount * BLOOM_GAIN,
offset: amount * BLOOM_OFFSET,
}
}
pub fn paint(surface: &mut Surface, stars: &[Star], bloom: Bloom, background: Rgb) {
let width = surface.width();
let cell_rows = surface.height() / 2;
if width == 0 || cell_rows == 0 {
return;
}
let ccx = width as f32 / 2.0;
let ccy = cell_rows as f32 / 2.0;
for star in stars {
let (mut x, mut cy) = (star.x, star.cell_y);
let mut brightness = star.brightness;
if star.near {
brightness = (brightness + bloom.gain).min(1.0);
if bloom.offset > 0.0 {
let dx = x as f32 + 0.5 - ccx;
let dy = cy as f32 + 0.5 - ccy;
let len = (dx * dx + dy * dy).sqrt().max(0.001);
let ox = (x as f32 + 0.5 + dx / len * bloom.offset).floor();
let oy = (cy as f32 + 0.5 + dy / len * bloom.offset).floor();
if ox >= 0.0 && oy >= 0.0 && (ox as usize) < width && (oy as usize) < cell_rows {
x = ox as usize;
cy = oy as usize;
}
}
}
if surface.get(x, cy * 2) != background || surface.get(x, cy * 2 + 1) != background {
continue;
}
surface.set_glyph(
x,
cy,
star.glyph,
Rgb::lerp(background, STAR_COLOR, brightness),
);
}
}
fn unit(state: &mut u64) -> f32 {
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = *state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^= z >> 31;
(z >> 40) as f32 / (1u64 << 24) as f32
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generation_is_deterministic() {
let a = Starfield::new(7).cells(80, 24, 5.0);
let b = Starfield::new(7).cells(80, 24, 5.0);
assert_eq!(a, b);
assert!(!a.is_empty());
}
#[test]
fn clearing_excludes_stars_near_the_orb() {
let r = 12.0;
let stars = Starfield::new(3).cells(80, 24, r);
let (ocx, ocy) = (40.0_f32, 12.0_f32);
for s in &stars {
let px = s.x as f32 + 0.5;
let py = (s.cell_y * 2) as f32 + 1.0;
let dist = ((px - ocx).powi(2) + (py - ocy).powi(2)).sqrt();
assert!(
dist >= r,
"star {:?} is inside the clearing",
(s.x, s.cell_y)
);
}
}
#[test]
fn resize_reflows_rather_than_reshuffles() {
let field = Starfield::new(11);
let small = field.cells(80, 24, 0.0);
let large = field.cells(160, 48, 0.0);
assert_eq!(small.len(), large.len());
for (s, l) in small.iter().zip(large.iter()) {
assert_eq!(s.glyph, l.glyph);
assert_eq!(s.brightness, l.brightness);
assert_eq!(s.near, l.near);
}
}
#[test]
fn tiers_separate_near_bright_from_far_dim() {
let stars = Starfield::new(5).cells(200, 60, 0.0);
for s in &stars {
if s.near {
assert!(s.brightness >= NEAR_BRIGHT.0);
} else {
assert!(s.brightness <= FAR_BRIGHT.1);
}
}
assert!(NEAR_BRIGHT.0 > FAR_BRIGHT.1);
}
#[test]
fn degenerate_sizes_produce_no_stars_and_no_panic() {
assert!(Starfield::new(1).cells(0, 24, 5.0).is_empty());
assert!(Starfield::new(1).cells(80, 1, 5.0).is_empty());
assert!(Starfield::new(1).cells(80, 0, 5.0).is_empty());
}
#[test]
fn bloom_peaks_on_exhale_and_settles_on_inhale() {
assert!(bloom(Phase::Exhale, 1.0).gain > 0.0);
assert!(bloom(Phase::Exhale, 1.0).offset > 0.0);
assert_eq!(bloom(Phase::HoldOut, 0.5).gain, BLOOM_GAIN);
assert_eq!(bloom(Phase::Inhale, 1.0).gain, 0.0);
assert_eq!(bloom(Phase::HoldIn, 0.5).gain, 0.0);
assert_eq!(bloom(Phase::Still, 0.5).gain, 0.0);
assert!(bloom(Phase::Exhale, 1.0).gain <= BLOOM_GAIN);
assert!(bloom(Phase::Exhale, 1.0).offset <= BLOOM_OFFSET);
}
#[test]
fn paint_drops_stars_on_orb_cells_and_places_them_elsewhere() {
let bg = Rgb::new(6, 8, 14);
let mut surface = Surface::new(4, 4, bg); surface.set(1, 0, Rgb::new(96, 138, 102)); let stars = vec![
Star {
x: 1,
cell_y: 0,
glyph: '✦',
brightness: 0.9,
near: false,
},
Star {
x: 3,
cell_y: 1,
glyph: '·',
brightness: 0.3,
near: false,
},
];
paint(
&mut surface,
&stars,
Bloom {
gain: 0.0,
offset: 0.0,
},
bg,
);
assert_eq!(surface.glyph(1, 0), None); assert!(surface.glyph(3, 1).is_some()); }
#[test]
fn mono_renders_field_as_glyphs_without_color() {
use crate::render::mono::Mono;
use crate::render::Renderer;
let bg = Rgb::new(6, 8, 14);
let field = Starfield::new(7);
let mut surface = Surface::new(80, 24, bg);
let stars = field.cells(80, 24, 0.0);
paint(
&mut surface,
&stars,
Bloom {
gain: 0.0,
offset: 0.0,
},
bg,
);
let out = Mono.encode(&surface);
assert!(!out.contains('\x1b')); assert!(stars.iter().any(|s| out.contains(s.glyph))); }
#[test]
fn small_terminal_suppresses_the_field() {
assert!(Starfield::new(2).cells(20, 6, 5.0).is_empty());
assert!(!Starfield::new(2).cells(60, 20, 8.0).is_empty());
}
#[test]
fn paint_blooms_near_star_outward_in_bounds() {
let bg = Rgb::new(6, 8, 14);
let mut surface = Surface::new(40, 24, bg); let stars = vec![Star {
x: 4,
cell_y: 2,
glyph: '✦',
brightness: 0.6,
near: true,
}];
paint(
&mut surface,
&stars,
Bloom {
gain: 0.3,
offset: 1.5,
},
bg,
);
let placed: Vec<(usize, usize)> = (0..40)
.flat_map(|x| (0..12).map(move |cy| (x, cy)))
.filter(|&(x, cy)| surface.glyph(x, cy).is_some())
.collect();
assert_eq!(placed.len(), 1, "exactly one in-bounds star glyph");
assert!(
placed[0].0 <= 4,
"near star eased outward (away from center)"
);
}
}