hotline 0.0.1

Draw lines that are on fire using SDL3
Documentation
//! # hotline
//!
//! A Rust library for drawing lines that are on fire using SDL3.
//!
//! ## Example
//!
//! ```rust,no_run
//! use hotline::{FireLine, FireRenderer, FireParams};
//! use sdl3::pixels::Color;
//!
//! let sdl = sdl3::init().unwrap();
//! let video = sdl.video().unwrap();
//! let window = video.window("Fire Lines", 800, 600)
//!     .position_centered()
//!     .build()
//!     .unwrap();
//! let canvas = window.into_canvas();
//!
//! let mut renderer = FireRenderer::new(canvas);
//! let fire_line = FireLine::new(100.0, 300.0, 700.0, 300.0, FireParams::default());
//!
//! renderer.render(&fire_line, 0.0);
//! renderer.present();
//! ```

use noise::{NoiseFn, Perlin};
use rand::Rng;
use sdl3::pixels::Color;
use sdl3::render::{Canvas, FPoint};
use sdl3::video::Window;

/// Parameters controlling the fire effect appearance
#[derive(Debug, Clone, Copy)]
pub struct FireParams {
    /// Height of the flames above the line (in pixels)
    pub flame_height: f32,
    /// Intensity of the fire (0.0 to 1.0)
    pub intensity: f32,
    /// Speed of flickering animation
    pub flicker_speed: f32,
    /// Density of flame particles
    pub particle_density: f32,
    /// Width of the flame effect
    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,
        }
    }
}

/// Represents a line that will be rendered with fire effect
#[derive(Debug, Clone, Copy)]
pub struct FireLine {
    pub start: FPoint,
    pub end: FPoint,
    pub params: FireParams,
}

impl FireLine {
    /// Create a new fire line from coordinates and parameters
    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,
        }
    }

    /// Get the length of the line
    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()
    }
}

/// Renderer for fire lines using SDL3
pub struct FireRenderer {
    canvas: Canvas<Window>,
    noise: Perlin,
}

impl FireRenderer {
    /// Create a new fire renderer with the given canvas
    pub fn new(canvas: Canvas<Window>) -> Self {
        Self {
            canvas,
            noise: Perlin::new(rand::rng().random()),
        }
    }

    /// Get a mutable reference to the canvas
    pub fn canvas_mut(&mut self) -> &mut Canvas<Window> {
        &mut self.canvas
    }

    /// Present the rendered frame
    pub fn present(&mut self) {
        self.canvas.present();
    }

    /// Render a fire line at the given time
    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;

            // Interpolate along the line
            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);

            // Generate flame particles above this point
            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;

                // Add horizontal spread
                let x_offset = (rand::rng().random::<f32>() - 0.5) * line.params.flame_width;
                let px = x + x_offset;

                // Sample noise for this particle
                let noise_val = self.noise.get([
                    px as f64 * 0.05,
                    y as f64 * 0.05,
                    (time * line.params.flicker_speed) as f64,
                ]);

                // Normalize noise to 0..1
                let noise_normalized = ((noise_val + 1.0) * 0.5).clamp(0.0, 1.0) as f32;

                // Height factor: flames fade as they rise
                let height_factor = 1.0 - (y_offset / line.params.flame_height);

                // Combine factors for final intensity
                let intensity = noise_normalized * height_factor * line.params.intensity;

                // Skip transparent pixels
                if intensity < 0.1 {
                    continue;
                }

                // Map intensity to fire color gradient
                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(())
    }
}

/// Maps intensity (0.0 to 1.0) to fire colors
/// black -> red -> orange -> yellow -> white
fn fire_color(intensity: f32) -> Color {
    let intensity = intensity.clamp(0.0, 1.0);

    if intensity < 0.25 {
        // black to dark red
        let t = intensity / 0.25;
        Color::RGB((t * 139.0) as u8, 0, 0)
    } else if intensity < 0.5 {
        // dark red to red
        let t = (intensity - 0.25) / 0.25;
        Color::RGB((139.0 + t * 116.0) as u8, 0, 0)
    } else if intensity < 0.75 {
        // red to orange
        let t = (intensity - 0.5) / 0.25;
        Color::RGB(255, (t * 165.0) as u8, 0)
    } else if intensity < 0.9 {
        // orange to yellow
        let t = (intensity - 0.75) / 0.15;
        Color::RGB(255, (165.0 + t * 90.0) as u8, (t * 255.0) as u8)
    } else {
        // yellow to white
        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);

        // Just verify they're different and progressively brighter
        assert!(black.r < red.r);
        assert!(red.g < orange.g);
        assert!(orange.b < yellow.b);
    }
}