use noise::{NoiseFn, Perlin};
use rand::Rng;
use sdl3::pixels::Color;
use sdl3::render::{Canvas, FPoint};
use sdl3::video::Window;
#[derive(Debug, Clone, Copy)]
pub struct FireParams {
pub flame_height: f32,
pub intensity: f32,
pub flicker_speed: f32,
pub particle_density: f32,
pub flame_width: f32,
}
impl Default for FireParams {
fn default() -> Self {
Self {
flame_height: 50.0,
intensity: 1.0,
flicker_speed: 2.0,
particle_density: 0.5,
flame_width: 20.0,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct FireLine {
pub start: FPoint,
pub end: FPoint,
pub params: FireParams,
}
impl FireLine {
pub fn new(x1: f32, y1: f32, x2: f32, y2: f32, params: FireParams) -> Self {
Self {
start: FPoint::new(x1, y1),
end: FPoint::new(x2, y2),
params,
}
}
pub fn length(&self) -> f32 {
let dx = self.end.x - self.start.x;
let dy = self.end.y - self.start.y;
(dx * dx + dy * dy).sqrt()
}
}
pub struct FireRenderer {
canvas: Canvas<Window>,
noise: Perlin,
}
impl FireRenderer {
pub fn new(canvas: Canvas<Window>) -> Self {
Self {
canvas,
noise: Perlin::new(rand::rng().random()),
}
}
pub fn canvas_mut(&mut self) -> &mut Canvas<Window> {
&mut self.canvas
}
pub fn present(&mut self) {
self.canvas.present();
}
pub fn render(&mut self, line: &FireLine, time: f32) -> Result<(), String> {
let length = line.length();
let num_samples = (length * line.params.particle_density) as usize;
if num_samples == 0 {
return Ok(());
}
for i in 0..num_samples {
let t = i as f32 / num_samples as f32;
let x = line.start.x + t * (line.end.x - line.start.x);
let base_y = line.start.y + t * (line.end.y - line.start.y);
let particles_per_sample = (line.params.flame_height * 0.5) as usize;
for p in 0..particles_per_sample {
let y_offset = p as f32;
let y = base_y - y_offset;
let x_offset = (rand::rng().random::<f32>() - 0.5) * line.params.flame_width;
let px = x + x_offset;
let noise_val = self.noise.get([
px as f64 * 0.05,
y as f64 * 0.05,
(time * line.params.flicker_speed) as f64,
]);
let noise_normalized = ((noise_val + 1.0) * 0.5).clamp(0.0, 1.0) as f32;
let height_factor = 1.0 - (y_offset / line.params.flame_height);
let intensity = noise_normalized * height_factor * line.params.intensity;
if intensity < 0.1 {
continue;
}
let color = fire_color(intensity);
self.canvas.set_draw_color(color);
self.canvas.draw_point(FPoint::new(px, y)).map_err(|e| e.to_string())?;
}
}
Ok(())
}
}
fn fire_color(intensity: f32) -> Color {
let intensity = intensity.clamp(0.0, 1.0);
if intensity < 0.25 {
let t = intensity / 0.25;
Color::RGB((t * 139.0) as u8, 0, 0)
} else if intensity < 0.5 {
let t = (intensity - 0.25) / 0.25;
Color::RGB((139.0 + t * 116.0) as u8, 0, 0)
} else if intensity < 0.75 {
let t = (intensity - 0.5) / 0.25;
Color::RGB(255, (t * 165.0) as u8, 0)
} else if intensity < 0.9 {
let t = (intensity - 0.75) / 0.15;
Color::RGB(255, (165.0 + t * 90.0) as u8, (t * 255.0) as u8)
} else {
let t = (intensity - 0.9) / 0.1;
Color::RGB(255, 255, (255.0 + t * 0.0) as u8)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fire_line_creation() {
let line = FireLine::new(0.0, 0.0, 100.0, 0.0, FireParams::default());
assert_eq!(line.start.x(), 0.0);
assert_eq!(line.start.y(), 0.0);
assert_eq!(line.end.x(), 100.0);
assert_eq!(line.end.y(), 0.0);
}
#[test]
fn test_fire_line_length() {
let line = FireLine::new(0.0, 0.0, 3.0, 4.0, FireParams::default());
assert!((line.length() - 5.0).abs() < 0.001);
}
#[test]
fn test_fire_color_gradient() {
let black = fire_color(0.0);
let red = fire_color(0.5);
let orange = fire_color(0.7);
let yellow = fire_color(0.85);
let white = fire_color(1.0);
assert!(black.r < red.r);
assert!(red.g < orange.g);
assert!(orange.b < yellow.b);
}
}