use super::{
scale::{capped, scaled, SUBPIXELS},
FULL_SCALE, MAX_SCALE,
};
use crate::{Color, Context, Graphics};
#[derive(Debug)]
pub struct Explosion<const SCALE: usize = FULL_SCALE, const SPARKS: usize = DEFAULT_SPARKS> {
sparks: [Spark; SPARKS],
x: i16,
y: i16,
color: Color,
age: u8,
life: u8,
}
impl<const SCALE: usize, const SPARKS: usize> Explosion<SCALE, SPARKS> {
pub const fn new(x: i16, y: i16) -> Self {
const {
assert!(SCALE > 0, "an explosion needs a non-zero SCALE");
assert!(
SCALE <= MAX_SCALE,
"an explosion's SCALE must not exceed MAX_SCALE"
);
assert!(SPARKS > 0, "an explosion needs at least one spark");
}
Self {
sparks: [Spark::UNTHROWN; SPARKS],
x,
y,
color: SPARK_COLOR,
age: 0,
life: 0,
}
}
pub const fn with_color(mut self, color: Color) -> Self {
self.color = color;
self
}
pub fn update(&mut self, ctx: &mut Context) {
if self.life == 0 {
self.throw(ctx);
return;
}
if self.age < self.life {
self.age += 1;
}
}
pub fn draw(&self, gfx: &mut Graphics) {
let age = self.age as i32;
let shrunk = Self::shrunk(age);
let base_x = self.x as i32 * SUBPIXELS;
let base_y = self.y as i32 * SUBPIXELS;
for spark in &self.sparks {
let radius = spark.radius as i32 - shrunk;
if radius <= 0 {
continue;
}
let x = whole_pixels(base_x + spark.x_step as i32 * age);
let y = whole_pixels(base_y + spark.y_step as i32 * age);
gfx.circle_fill(x, y, (radius / SUBPIXELS) as u16, self.color);
}
}
pub fn finished(&self) -> bool {
self.life > 0 && self.age >= self.life
}
fn throw(&mut self, ctx: &mut Context) {
let scale = SCALE as i32;
let mut fattest = 0;
for spark in &mut self.sparks {
*spark = Spark::new(ctx, scale);
fattest = fattest.max(spark.radius);
}
let shrink = SPARK_SHRINK_SPEED * SCALE as i32;
let life = (fattest as i32 * FULL_SCALE as i32 + shrink - 1) / shrink;
self.life = capped(life).max(1);
}
fn shrunk(age: i32) -> i32 {
scaled(SPARK_SHRINK_SPEED * age, SCALE as i32)
}
}
pub const DEFAULT_SPARKS: usize = 50;
#[derive(Debug, Clone, Copy)]
struct Spark {
x_step: i16,
y_step: i16,
radius: u8,
}
impl Spark {
const UNTHROWN: Self = Self {
x_step: 0,
y_step: 0,
radius: 0,
};
fn new(ctx: &mut Context, scale: i32) -> Self {
Self {
x_step: scaled(spark_step(ctx), scale) as i16,
y_step: scaled(spark_step(ctx), scale) as i16,
radius: capped(scaled(
ctx.random_integer(MIN_SPARK_RADIUS..MAX_SPARK_RADIUS),
scale,
)),
}
}
}
fn spark_step(ctx: &mut Context) -> i32 {
let velocity = ctx.random_integer(-SPARK_SPEED..SPARK_SPEED);
let mass = ctx.random_integer(MIN_SPARK_MASS..MAX_SPARK_MASS);
velocity * SUBPIXELS / mass
}
fn whole_pixels(position: i32) -> i16 {
(position / SUBPIXELS).clamp(i16::MIN as i32, i16::MAX as i32) as i16
}
const SPARK_COLOR: Color = Color::LIGHT_GREY;
const SPARK_SPEED: i32 = SUBPIXELS;
const MIN_SPARK_MASS: i32 = SUBPIXELS / 2;
const MAX_SPARK_MASS: i32 = SUBPIXELS * 5 / 2;
const MIN_SPARK_RADIUS: i32 = SUBPIXELS / 2;
const MAX_SPARK_RADIUS: i32 = SUBPIXELS * 3 / 2;
const SPARK_SHRINK_SPEED: i32 = SUBPIXELS / 10;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_explosion_is_nothing_until_it_is_run() {
let mut ctx = Context { _private: () };
let mut blast: Explosion = Explosion::new(64, 64);
assert!(!blast.finished());
assert!(blast.sparks.iter().all(|spark| spark.radius == 0));
blast.update(&mut ctx);
assert!(!blast.finished());
assert!(blast.sparks.iter().all(|spark| spark.radius > 0));
}
#[test]
fn an_explosion_is_finished_once_its_sparks_are_spent() {
let mut ctx = Context { _private: () };
let mut blast: Explosion = Explosion::new(64, 64);
blast.update(&mut ctx);
let fattest = blast.sparks.iter().map(|spark| spark.radius).max().unwrap() as i32;
let shrunk = Explosion::<FULL_SCALE, DEFAULT_SPARKS>::shrunk;
for age in 1..blast.life {
blast.update(&mut ctx);
assert!(!blast.finished(), "over at {age} of {}", blast.life);
assert!(
fattest - shrunk(age as i32) > 0,
"the fattest spark was gone at {age}"
);
}
blast.update(&mut ctx);
assert!(blast.finished(), "still going at {}", blast.life);
assert!(fattest - shrunk(blast.age as i32) <= 0);
for _ in 0..10 * u8::MAX as usize {
blast.update(&mut ctx);
assert!(blast.finished());
}
}
#[test]
fn a_smaller_explosion_throws_its_sparks_less_far() {
let mut ctx = Context { _private: () };
let mut full: Explosion = Explosion::new(64, 64);
let mut small: Explosion<2> = Explosion::new(64, 64);
full.update(&mut ctx);
small.update(&mut ctx);
let (full, small) = (&full.sparks[0], &small.sparks[0]);
assert!(full.x_step.abs() > small.x_step.abs());
assert!(full.y_step.abs() > small.y_step.abs());
assert!(full.radius > small.radius);
}
#[test]
fn every_size_of_explosion_is_over_in_the_same_time() {
fn life<const SCALE: usize>() -> i32 {
let fattest = capped(scaled(MAX_SPARK_RADIUS - 1, SCALE as i32)) as i32;
(1..)
.find(|&age| Explosion::<SCALE>::shrunk(age) >= fattest)
.unwrap()
}
let full = life::<FULL_SCALE>();
assert_eq!(full, 16, "a full-size burst is no longer a quarter-second");
for (scale, life) in [
(1, life::<1>()),
(3, life::<3>()),
(20, life::<MAX_SCALE>()),
] {
assert!(
(life - full).abs() <= 1,
"a burst at scale {scale} lasts {life} updates against {full}"
);
}
}
const BLAST: Explosion<5, 10> = Explosion::new(3, 4).with_color(Color::YELLOW);
#[test]
fn a_const_built_explosion_matches_the_same_chain_run_at_start_up() {
let mut from_const = BLAST;
let mut from_call: Explosion<5, 10> = Explosion::new(3, 4).with_color(Color::YELLOW);
assert_eq!(from_const.x, from_call.x);
assert_eq!(from_const.y, from_call.y);
assert_eq!(from_const.color, from_call.color);
let mut ctx = Context { _private: () };
from_const.update(&mut ctx);
from_call.update(&mut ctx);
for (a, b) in from_const.sparks.iter().zip(from_call.sparks.iter()) {
assert_eq!(a.x_step, b.x_step);
assert_eq!(a.y_step, b.y_step);
assert_eq!(a.radius, b.radius);
}
}
}