use core::{array, ops::Range};
use heapless::Deque;
use super::{
fire::FIRE_SPEED,
scale::{capped, scaled, SUBPIXELS},
MAX_SCALE,
};
#[cfg(feature = "physics")]
use crate::physics::Wind;
use crate::{Color, Context, Direction, Graphics};
pub const DEFAULT_LIFETIME: usize = 26;
pub const MAX_LIFETIME: usize = {
let reach = i16::MAX as i32 - scaled(SPAWN_REACH, MAX_SCALE as i32);
(reach / scaled(MAX_PARTICLE_SPEED, MAX_SCALE as i32)) as usize
};
const MAX_PARTICLE_SPEED: i32 = FIRE_SPEED.end;
#[derive(Debug)]
pub(super) struct Plume<const SCALE: usize, const LIFETIME: usize> {
generations: Deque<Generation<SCALE>, LIFETIME>,
force: f32,
forced: f32,
x: i16,
y: i16,
direction: Direction,
#[cfg(feature = "physics")]
drift: Option<(i32, i32)>,
speed: Range<i32>,
slow_after: Option<usize>,
interval: u8,
life: u8,
waited: u8,
puffing: bool,
}
impl<const SCALE: usize, const LIFETIME: usize> Plume<SCALE, LIFETIME> {
pub(super) const fn new(x: i16, y: i16, speed: Range<i32>, slow_after: Option<usize>) -> Self {
const {
assert!(SCALE > 0, "a plume needs a non-zero SCALE");
assert!(
SCALE <= MAX_SCALE,
"a plume's SCALE must not exceed MAX_SCALE"
);
assert!(LIFETIME > 0, "a plume needs a non-zero LIFETIME");
assert!(
LIFETIME <= MAX_LIFETIME,
"a plume's LIFETIME must not exceed MAX_LIFETIME"
);
}
debug_assert!(
speed.end <= MAX_PARTICLE_SPEED,
"a plume may not outrun MAX_PARTICLE_SPEED"
);
Self {
generations: Deque::new(),
force: STARTING_FORCE,
forced: STARTING_FORCED,
x,
y,
direction: Direction::Up,
#[cfg(feature = "physics")]
drift: None,
speed,
slow_after,
interval: 1,
life: LIFETIME as u8,
waited: 0,
puffing: true,
}
}
pub(super) const fn set_puffs(&mut self, puffs: usize) {
let puffs = if puffs < 1 {
1
} else if puffs > LIFETIME {
LIFETIME
} else {
puffs
};
self.interval = (LIFETIME / puffs) as u8;
self.life = (puffs * (LIFETIME / puffs)) as u8;
}
pub(super) fn set_puffing(&mut self, puffing: bool) {
self.puffing = puffing;
}
pub(super) fn move_to(&mut self, x: i16, y: i16) {
self.x = x;
self.y = y;
}
pub(super) const fn set_direction(&mut self, direction: Direction) {
self.direction = direction;
}
#[cfg(feature = "physics")]
pub(super) fn blown_by(&mut self, wind: &Wind) {
let (x, y) = wind.blow();
let x = x.clamp(-MAX_WIND_SPEED, MAX_WIND_SPEED);
let y = y.clamp(-MAX_WIND_SPEED, MAX_WIND_SPEED);
self.drift = Some(((x * SUBPIXELS as f32) as i32, (y * SUBPIXELS as f32) as i32));
}
pub(super) fn update(&mut self, ctx: &mut Context) {
if self.force < -MAX_FORCE || self.force > ctx.random(0.0..MAX_FORCE) {
self.forced = -self.forced;
}
self.force += self.forced;
let scale = SCALE as i32;
self.waited += 1;
let interval = self.interval;
let ticking = self.waited >= interval;
if ticking {
self.waited = 0;
for generation in self.generations.iter_mut() {
generation.age += interval;
}
if self.generations.front().is_some_and(|g| g.age >= self.life) {
self.generations.pop_front();
}
if self.puffing {
let speed = self.speed.clone();
let spawned = self.generations.push_back(Generation {
particles: array::from_fn(|_| Particle::new(ctx, scale, speed.clone())),
x: self.x,
y: self.y,
direction: self.direction,
age: 0,
});
debug_assert!(
spawned.is_ok(),
"expiring the oldest leaves room for a generation"
);
}
}
#[cfg(feature = "physics")]
let swaying = self.drift.is_none();
#[cfg(not(feature = "physics"))]
let swaying = true;
let force = if swaying {
scaled((self.force * SUBPIXELS as f32) as i32, scale) as i16
} else {
0
};
let shrink = capped(scaled(RADIUS_SHRINK_SPEED, scale)).max(1);
let slow_after = self.slow_after;
#[cfg(feature = "physics")]
let (drift, waited) = (self.drift, self.waited as usize);
for generation in self.generations.iter_mut() {
let age = generation.age as usize;
let slowing = ticking
&& slow_after.is_some_and(|after| age >= after && age < after + interval as usize);
#[cfg(feature = "physics")]
let (drift_x, drift_y) = drift.map_or((0, 0), |drift| {
drifted(drift, generation.direction, age + waited)
});
for particle in &mut generation.particles {
particle.x = particle.x.saturating_add(force);
particle.y = particle.y.saturating_sub(particle.speed as i16);
#[cfg(feature = "physics")]
{
particle.x = particle.x.saturating_add(drift_x);
particle.y = particle.y.saturating_add(drift_y);
}
particle.radius = particle.radius.saturating_sub(shrink);
if slowing {
particle.speed /= 2;
}
}
}
}
pub(super) fn draw(&self, gfx: &mut Graphics, birth: Color, stops: &[(usize, Color)]) {
for generation in &self.generations {
let age = generation.age as usize;
let mut color = birth;
for &(stop, stop_color) in stops {
if age >= stop {
color = stop_color;
}
}
let base_x = generation.x as i32 * SUBPIXELS;
let base_y = generation.y as i32 * SUBPIXELS;
for particle in &generation.particles {
let (x, y) = generation
.direction
.rotate(particle.x as i32, particle.y as i32);
let x = ((base_x + x) / SUBPIXELS) as i16;
let y = ((base_y + y) / SUBPIXELS) as i16;
let radius = (particle.radius / SUBPIXELS as u8) as u16;
gfx.circle_fill(x, y, radius, color);
}
}
}
}
#[derive(Debug)]
struct Generation<const SCALE: usize> {
particles: [Particle; SCALE],
x: i16,
y: i16,
direction: Direction,
age: u8,
}
#[derive(Debug)]
struct Particle {
x: i16,
y: i16,
radius: u8,
speed: u8,
}
impl Particle {
fn new(ctx: &mut Context, scale: i32, speed: Range<i32>) -> Self {
Self {
x: scaled(ctx.random_integer(-SPAWN_REACH..SPAWN_REACH), scale) as i16,
y: scaled(ctx.random_integer(-SPAWN_REACH..SPAWN_REACH), scale) as i16,
radius: capped(scaled(ctx.random_integer(MIN_RADIUS..MAX_RADIUS), scale)),
speed: capped(scaled(ctx.random_integer(speed), scale)),
}
}
}
#[cfg(feature = "physics")]
fn drifted(drift: (i32, i32), direction: Direction, age: usize) -> (i16, i16) {
let lived = (age as i32).min(WIND_RAMP_UPDATES);
let ramped = |drift: i32| {
drift * (WIND_RAMP_UPDATES + (WIND_AT_BIRTH - 1) * lived)
/ (WIND_AT_BIRTH * WIND_RAMP_UPDATES)
};
let (x, y) = direction.unrotate(ramped(drift.0), ramped(drift.1));
(x as i16, y as i16)
}
impl Direction {
#[cfg(any(feature = "physics", test))]
fn unrotate(self, x: i32, y: i32) -> (i32, i32) {
let back = match self {
Self::Up => Self::Up,
Self::UpRight => Self::UpLeft,
Self::Right => Self::Left,
Self::DownRight => Self::DownLeft,
Self::Down => Self::Down,
Self::DownLeft => Self::DownRight,
Self::Left => Self::Right,
Self::UpLeft => Self::UpRight,
};
back.rotate(x, y)
}
fn rotate(self, x: i32, y: i32) -> (i32, i32) {
match self {
Self::Up => (x, y),
Self::UpRight => diagonal(x - y, x + y),
Self::Right => (-y, x),
Self::DownRight => diagonal(-x - y, x - y),
Self::Down => (-x, -y),
Self::DownLeft => diagonal(y - x, -x - y),
Self::Left => (y, -x),
Self::UpLeft => diagonal(x + y, y - x),
}
}
}
fn diagonal(x: i32, y: i32) -> (i32, i32) {
(x * 181 / 256, y * 181 / 256)
}
const STARTING_FORCE: f32 = 0.0;
const STARTING_FORCED: f32 = 0.005;
const MAX_FORCE: f32 = 0.25;
#[cfg(feature = "physics")]
pub const MAX_WIND_SPEED: f32 = 4.0;
#[cfg(feature = "physics")]
const WIND_RAMP_UPDATES: i32 = DEFAULT_LIFETIME as i32;
#[cfg(feature = "physics")]
const WIND_AT_BIRTH: i32 = 2;
const SPAWN_REACH: i32 = 5 * SUBPIXELS;
const MIN_RADIUS: i32 = 0;
const MAX_RADIUS: i32 = SUBPIXELS * 3;
const RADIUS_SHRINK_SPEED: i32 = SUBPIXELS / 20;
#[cfg(test)]
mod tests {
use super::{super::FULL_SCALE, *};
const TEST_SPEED: Range<i32> = SUBPIXELS / 4..SUBPIXELS * 3 / 4;
const DIRECTIONS: [Direction; 8] = [
Direction::Up,
Direction::UpRight,
Direction::Right,
Direction::DownRight,
Direction::Down,
Direction::DownLeft,
Direction::Left,
Direction::UpLeft,
];
const ROUNDING: i32 = 2;
#[test]
fn unrotating_a_rotation_gives_the_offset_back() {
for direction in DIRECTIONS {
for x in (-4 * SUBPIXELS..=4 * SUBPIXELS).step_by(37) {
for y in (-4 * SUBPIXELS..=4 * SUBPIXELS).step_by(53) {
let (screen_x, screen_y) = direction.rotate(x, y);
let (back_x, back_y) = direction.unrotate(screen_x, screen_y);
assert!(
(back_x - x).abs() <= ROUNDING && (back_y - y).abs() <= ROUNDING,
"{direction:?}: ({x}, {y}) came back as ({back_x}, {back_y})"
);
let (plume_x, plume_y) = direction.unrotate(x, y);
let (back_x, back_y) = direction.rotate(plume_x, plume_y);
assert!(
(back_x - x).abs() <= ROUNDING && (back_y - y).abs() <= ROUNDING,
"{direction:?}: ({x}, {y}) came back as ({back_x}, {back_y})"
);
}
}
}
}
#[test]
fn unrotating_turns_a_screen_wind_across_the_plume() {
assert_eq!(Direction::Up.unrotate(SUBPIXELS, 0), (SUBPIXELS, 0));
assert_eq!(Direction::Down.unrotate(SUBPIXELS, 0), (-SUBPIXELS, 0));
assert_eq!(Direction::Right.unrotate(SUBPIXELS, 0), (0, -SUBPIXELS));
assert_eq!(Direction::Left.unrotate(SUBPIXELS, 0), (0, SUBPIXELS));
let (x, y) = Direction::UpLeft.unrotate(SUBPIXELS, 0);
assert_eq!((x, y), (SUBPIXELS * 181 / 256, SUBPIXELS * 181 / 256));
}
#[cfg(feature = "physics")]
#[test]
fn the_wind_ramp_climbs_with_every_update_lived() {
let drift = (SUBPIXELS, 0);
let born = drifted(drift, Direction::Up, 0);
assert_eq!(born, ((drift.0 / WIND_AT_BIRTH) as i16, 0));
let mut previous = born.0;
for age in 1..WIND_RAMP_UPDATES as usize {
let (x, y) = drifted(drift, Direction::Up, age);
assert!(x >= previous, "the ramp went backwards at {age}");
assert_eq!(y, 0);
previous = x;
}
let full = drifted(drift, Direction::Up, WIND_RAMP_UPDATES as usize);
assert_eq!(full, (drift.0 as i16, 0));
assert_eq!(drifted(drift, Direction::Up, 10_000), full);
assert!(full.0 > born.0, "{full:?} is no further than {born:?}");
let carried = |updates: i32| -> i32 {
(0..updates)
.map(|age| drifted(drift, Direction::Up, age as usize).0 as i32)
.sum()
};
let flame = carried(WIND_RAMP_UPDATES);
assert!(
carried(2 * WIND_RAMP_UPDATES) > 2 * flame,
"the wind carries the whole plume alike"
);
}
#[cfg(feature = "physics")]
#[test]
fn a_plume_takes_the_whole_of_a_wind_and_not_just_its_x() {
let mut plume: Plume<4, 26> = Plume::new(0, 0, TEST_SPEED, None);
plume.blown_by(&Wind::new(1.0).with_direction(Direction::Up));
assert_eq!(plume.drift, Some((0, SUBPIXELS)));
plume.blown_by(&Wind::new(-0.5));
assert_eq!(plume.drift, Some((-SUBPIXELS / 2, 0)));
plume.blown_by(&Wind::new(10.0 * MAX_WIND_SPEED).with_direction(Direction::Down));
assert_eq!(plume.drift, Some((0, -(MAX_WIND_SPEED as i32) * SUBPIXELS)));
plume.blown_by(&Wind::new(1.0).with_direction(Direction::UpLeft));
let (x, y) = plume.drift.unwrap();
assert!(x > 0 && y > 0, "a wind off the top left drifted ({x}, {y})");
let carried = drifted((0, SUBPIXELS), Direction::Up, WIND_RAMP_UPDATES as usize);
assert_eq!(carried, (0, SUBPIXELS as i16));
}
#[cfg(feature = "physics")]
#[test]
fn a_thinned_plume_leans_as_far_as_a_full_one() {
let mut ctx = Context { _private: () };
let mut still: Plume<4, 26> = Plume::new(0, 0, TEST_SPEED, None);
let mut blown: Plume<4, 26> = Plume::new(0, 0, TEST_SPEED, None);
still.set_puffs(1);
blown.set_puffs(1);
blown.blown_by(&Wind::new(2.0));
for _ in 0..26 + 14 {
still.update(&mut ctx);
blown.update(&mut ctx);
}
let calm = still.generations.front().unwrap();
let leaning = blown.generations.front().unwrap();
assert_eq!(calm.age, 0, "the puff was aged after all");
for (calm, leaning) in calm.particles.iter().zip(&leaning.particles) {
assert!(
leaning.x > calm.x,
"left standing in the wind: {} against {}",
leaning.x,
calm.x
);
}
}
#[cfg(feature = "physics")]
#[test]
fn a_wind_stands_in_for_the_sway() {
fn lateral(plume: &Plume<FULL_SCALE, 26>) -> [i16; FULL_SCALE] {
let mut x = [0; FULL_SCALE];
let particles = &plume.generations.front().unwrap().particles;
for (x, particle) in x.iter_mut().zip(particles) {
*x = particle.x;
}
x
}
let mut ctx = Context { _private: () };
let mut plume: Plume<FULL_SCALE, 26> = Plume::new(0, 0, TEST_SPEED, None);
plume.force = MAX_FORCE;
plume.forced = 0.0;
plume.update(&mut ctx);
let spawned = lateral(&plume);
plume.update(&mut ctx);
let swayed = lateral(&plume);
let lean = (MAX_FORCE * SUBPIXELS as f32) as i16;
for (before, after) in spawned.iter().zip(&swayed) {
assert_eq!(after - before, lean, "the sway is not what it was");
}
plume.blown_by(&Wind::new(0.0));
plume.update(&mut ctx);
assert_eq!(lateral(&plume), swayed, "the sway carried on under a wind");
plume.blown_by(&Wind::new(1.0));
plume.update(&mut ctx);
let ramped = drifted((SUBPIXELS, 0), Direction::Up, 3).0;
assert!(ramped > 0, "the wind never reached them");
for (before, after) in swayed.iter().zip(&lateral(&plume)) {
assert_eq!(after - before, ramped, "the wind is not blowing alone");
}
}
}