use crate::base::Rgba;
use crate::render::cell::Cell;
use crate::render::style::Style;
use crate::render::surface::Surface;
#[derive(Copy, Clone, Debug)]
pub struct Particle {
pub x: f32,
pub y: f32,
pub vx: f32,
pub vy: f32,
pub life: f32,
pub life0: f32,
pub color: Rgba,
}
#[derive(Copy, Clone, Debug)]
pub struct Burst {
pub origin: (f32, f32),
pub count: usize,
pub speed: (f32, f32),
pub life: (f32, f32),
pub colors: [Rgba; 3],
}
pub struct ParticleField {
rng: u64,
pub gravity: (f32, f32),
pub drag: f32,
particles: Vec<Particle>,
}
impl ParticleField {
pub fn new(seed: u64) -> ParticleField {
ParticleField {
rng: seed | 1, gravity: (0.0, 0.0),
drag: 1.0,
particles: Vec::new(),
}
}
pub fn len(&self) -> usize {
self.particles.len()
}
pub fn is_empty(&self) -> bool {
self.particles.is_empty()
}
pub fn particles(&self) -> &[Particle] {
&self.particles
}
fn next(&mut self) -> u64 {
self.rng ^= self.rng << 13;
self.rng ^= self.rng >> 7;
self.rng ^= self.rng << 17;
self.rng
}
fn uniform(&mut self, lo: f32, hi: f32) -> f32 {
let u = (self.next() >> 40) as f32 / (1u64 << 24) as f32; lo + (hi - lo) * u
}
pub fn spawn(&mut self, burst: Burst) {
self.particles.reserve(burst.count);
for _ in 0..burst.count {
let turns = self.uniform(0.0, 1.0);
let (dx, dy) = unit_dir(turns);
let speed = self.uniform(burst.speed.0, burst.speed.1);
let life = self.uniform(burst.life.0.max(0.01), burst.life.1.max(0.01));
let color = burst.colors[(self.next() % 3) as usize];
self.particles.push(Particle {
x: burst.origin.0,
y: burst.origin.1,
vx: dx * speed,
vy: dy * speed * 0.5,
life,
life0: life,
color,
});
}
}
pub fn step(&mut self, dt: f32) {
let dt = dt.max(0.0);
let drag = (1.0 - (1.0 - self.drag) * dt).clamp(0.0, 1.0);
for p in &mut self.particles {
p.vx += self.gravity.0 * dt;
p.vy += self.gravity.1 * dt * 0.5; p.vx *= drag;
p.vy *= drag;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.life -= dt;
}
self.particles.retain(|p| p.life > 0.0);
}
pub fn render(&self, s: &mut Surface) {
for p in &self.particles {
let x = p.x.round() as i32;
let y = p.y.round() as i32;
if x < 0 || y < 0 || x >= s.width() || y >= s.height() {
continue;
}
let frac = (p.life / p.life0).clamp(0.0, 1.0);
let glyph = if frac > 0.66 {
"•"
} else if frac > 0.33 {
"◦"
} else {
"·"
};
let fade = |v: u8| ((v as f32) * (0.35 + 0.65 * frac)).round() as u8;
let ink = Rgba::rgb(fade(p.color.r), fade(p.color.g), fade(p.color.b));
s.draw_text(x, y, glyph, Style::new().fg(ink));
}
}
pub fn render_clear(&self, s: &mut Surface) {
s.clear(Cell::EMPTY);
self.render(s);
}
}
fn unit_dir(turns: f32) -> (f32, f32) {
let sin = super::easing::poly_sin_turns(turns);
let cos = super::easing::poly_sin_turns(turns + 0.25);
(cos, sin)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::base::Size;
fn burst() -> Burst {
Burst {
origin: (10.0, 5.0),
count: 12,
speed: (2.0, 6.0),
life: (0.4, 0.8),
colors: [
Rgba::rgb(255, 200, 80),
Rgba::rgb(255, 120, 40),
Rgba::rgb(240, 240, 240),
],
}
}
#[test]
fn deterministic_for_a_seed() {
let run = |seed| {
let mut f = ParticleField::new(seed);
f.gravity = (0.0, 3.0);
f.spawn(burst());
let mut s = Surface::new(Size::new(24, 10), Cell::EMPTY);
for _ in 0..5 {
f.step(1.0 / 30.0);
}
f.render(&mut s);
format!("{s:?}")
};
assert_eq!(run(7), run(7), "same seed, same pixels");
assert_ne!(run(7), run(8), "different seed, different pixels");
}
#[test]
fn particles_move_age_and_retire() {
let mut f = ParticleField::new(42);
f.spawn(burst());
assert_eq!(f.len(), 12);
let before: Vec<(f32, f32)> = f.particles().iter().map(|p| (p.x, p.y)).collect();
f.step(0.1);
let moved = f
.particles()
.iter()
.zip(&before)
.filter(|(p, (x, y))| (p.x - x).abs() > 1e-6 || (p.y - y).abs() > 1e-6)
.count();
assert!(moved > 0, "particles move");
for _ in 0..30 {
f.step(0.05);
}
assert!(f.is_empty(), "lifetimes expire");
}
#[test]
fn gravity_and_drag_shape_the_motion() {
let mut f = ParticleField::new(1);
f.gravity = (0.0, 10.0);
f.spawn(Burst {
speed: (0.0, 0.0),
..burst()
});
f.step(0.5);
assert!(
f.particles().iter().all(|p| p.vy > 0.0),
"gravity pulls down"
);
let mut d = ParticleField::new(1);
d.drag = 0.0; d.spawn(Burst {
speed: (4.0, 4.0),
..burst()
});
let v0: f32 = d.particles().iter().map(|p| p.vx.abs() + p.vy.abs()).sum();
d.step(0.5);
let v1: f32 = d.particles().iter().map(|p| p.vx.abs() + p.vy.abs()).sum();
assert!(v1 < v0 * 0.6, "drag bleeds speed: {v0} -> {v1}");
}
#[test]
fn render_clips_and_respects_ground() {
let mut f = ParticleField::new(3);
f.spawn(Burst {
origin: (-5.0, -5.0),
..burst()
}); let mut s = Surface::new(Size::new(8, 4), Cell::EMPTY);
s.fill_rect(s.bounds(), Cell::EMPTY.with_bg(Rgba::rgb(9, 9, 20)));
f.render(&mut s); f.spawn(Burst {
origin: (4.0, 2.0),
speed: (0.0, 0.0),
..burst()
});
f.render(&mut s);
let cell = s.get(4, 2).unwrap();
assert_eq!(
cell.bg,
Rgba::rgb(9, 9, 20),
"particle ink patches over ground"
);
assert_eq!(s.glyph_str(cell), "•", "fresh particle glyph");
s.debug_validate().unwrap();
}
}