use crate::breath::{Phase, PhaseState};
use crate::palette::Palette;
use crate::render::{Rgb, Surface};
pub const MIN_SCALE: f32 = 0.45;
pub const MAX_SCALE: f32 = 1.0;
pub const STILL_SCALE: f32 = 0.7;
const RIPPLE_HALF_WIDTH: f32 = 1.6;
const EDGE_HALO_FRACTION: f32 = 0.14;
const EDGE_HALO_ALPHA: f32 = 0.8;
const VOICE_RING_RADII: [f32; 4] = [1.06, 1.20, 1.34, 1.48];
const VOICE_RING_OPACITY: [f32; 4] = [0.24, 0.18, 0.13, 0.09];
const VOICE_RING_IRREG: [f32; 4] = [0.6, -0.9, 0.4, -0.5];
const VOICE_RING_HALF_WIDTH: f32 = 1.2;
pub fn ease_in_out(t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
pub fn scale_for(state: PhaseState) -> f32 {
match state.phase {
Phase::Inhale => lerp(MIN_SCALE, MAX_SCALE, ease_in_out(state.progress)),
Phase::HoldIn => MAX_SCALE,
Phase::Exhale => lerp(MAX_SCALE, MIN_SCALE, ease_in_out(state.progress)),
Phase::HoldOut => MIN_SCALE,
Phase::Still => STILL_SCALE,
}
}
pub fn glow_for(state: PhaseState) -> f32 {
match state.phase {
Phase::HoldIn => 1.0,
Phase::HoldOut => 0.6,
_ => 0.0,
}
}
#[derive(Clone, Debug)]
pub struct OrbScene {
pub scale: f32,
pub glow: f32,
pub ripples: Vec<f32>,
pub milestone_flash: f32,
pub voice: f32,
pub voice_pulse: f32,
pub palette: Palette,
pub soft_edge: bool,
}
pub fn paint(surface: &mut Surface, scene: &OrbScene) {
surface.fill(scene.palette.background);
let width = surface.width();
let height = surface.height();
if width == 0 || height == 0 {
return;
}
let cx = width as f32 / 2.0;
let cy = height as f32 / 2.0;
let base = (width.min(height) as f32 / 2.0) * 0.92;
let radius = (base * scene.scale).max(1.0);
let voice = scene.voice.clamp(0.0, 1.0);
let pulse = scene.voice_pulse.clamp(0.0, 1.0);
let ring_scale = 0.97 + 0.07 * pulse;
let ring_opacity = 0.6 + 0.4 * pulse;
let soften = 1.0 - voice * 0.16;
for y in 0..height {
for x in 0..width {
let dx = x as f32 + 0.5 - cx;
let dy = y as f32 + 0.5 - cy;
let dist = (dx * dx + dy * dy).sqrt();
if dist <= radius {
let t = dist / radius;
let body = Rgb::lerp(scene.palette.core, scene.palette.edge, t);
surface.blend(x, y, body, (1.0 - t * 0.2) * soften);
if scene.glow > 0.0 {
let inner = 1.0 - (dist / (radius * 0.5)).min(1.0);
surface.blend(x, y, scene.palette.core, inner * scene.glow * 0.35 * soften);
}
} else if scene.soft_edge {
let halo = (radius * EDGE_HALO_FRACTION).max(1.5);
if dist <= radius + halo {
let f = 1.0 - (dist - radius) / halo;
surface.blend(x, y, scene.palette.edge, f * f * EDGE_HALO_ALPHA * soften);
}
}
if voice > 0.001 {
for i in 0..VOICE_RING_RADII.len() {
let rr = base * (VOICE_RING_RADII[i] + VOICE_RING_IRREG[i] * 0.02) * ring_scale;
let edge = (dist - rr).abs();
if edge < VOICE_RING_HALF_WIDTH {
let a = VOICE_RING_OPACITY[i]
* voice
* ring_opacity
* (1.0 - edge / VOICE_RING_HALF_WIDTH);
surface.blend(x, y, scene.palette.ripple, a);
}
}
}
for &life in &scene.ripples {
let ring_radius = lerp(base * 0.5, base * 1.2, life);
let edge = (dist - ring_radius).abs();
if edge < RIPPLE_HALF_WIDTH {
surface.blend(
x,
y,
scene.palette.ripple,
(1.0 - life) * 0.55 * (1.0 - edge / RIPPLE_HALF_WIDTH),
);
}
}
if scene.milestone_flash > 0.0 {
let edge = (dist - radius).abs();
if edge < 1.5 {
surface.blend(x, y, scene.palette.ripple, scene.milestone_flash * 0.5);
}
}
}
}
}
fn lerp(a: f32, b: f32, t: f32) -> f32 {
a + (b - a) * t
}
#[cfg(test)]
mod tests {
use super::*;
use crate::palette::{self, Season, TimeOfDay};
use crate::render::Surface;
fn scene(soft_edge: bool) -> OrbScene {
OrbScene {
scale: 1.0,
glow: 0.0,
ripples: Vec::new(),
milestone_flash: 0.0,
voice: 0.0,
voice_pulse: 0.0,
palette: palette::over_cosmos(palette::palette(Season::Summer, TimeOfDay::Day)),
soft_edge,
}
}
fn lit_cells(s: &Surface, bg: Rgb) -> usize {
let (w, h) = (s.width(), s.height());
(0..h)
.flat_map(|y| (0..w).map(move |x| (x, y)))
.filter(|&(x, y)| s.get(x, y) != bg)
.count()
}
#[test]
fn soft_edge_paints_a_halo_the_hard_edge_leaves_as_background() {
let bg = palette::CONSTELLATION_BG;
let (w, h) = (48, 48);
let mut hard = Surface::new(w, h, bg);
paint(&mut hard, &scene(false));
let mut soft = Surface::new(w, h, bg);
paint(&mut soft, &scene(true));
assert!(
lit_cells(&soft, bg) > lit_cells(&hard, bg),
"soft edge should light a halo beyond the rim"
);
}
#[test]
fn soft_edge_halo_stays_near_the_rim() {
let bg = palette::CONSTELLATION_BG;
let mut soft = Surface::new(48, 48, bg);
paint(&mut soft, &scene(true));
assert_eq!(soft.get(0, 0), bg, "the far corner must remain deep space");
}
}