Skip to main content

hotline/
lib.rs

1//! # hotline
2//!
3//! A Rust library for drawing lines that are on fire using SDL3.
4//!
5//! ## Example
6//!
7//! ```rust,no_run
8//! use hotline::{FireLine, FireRenderer, FireParams};
9//! use sdl3::pixels::Color;
10//!
11//! let sdl = sdl3::init().unwrap();
12//! let video = sdl.video().unwrap();
13//! let window = video.window("Fire Lines", 800, 600)
14//!     .position_centered()
15//!     .build()
16//!     .unwrap();
17//! let canvas = window.into_canvas();
18//!
19//! let mut renderer = FireRenderer::new(canvas);
20//! let fire_line = FireLine::new(100.0, 300.0, 700.0, 300.0, FireParams::default());
21//!
22//! renderer.render(&fire_line, 0.0);
23//! renderer.present();
24//! ```
25
26use noise::{NoiseFn, Perlin};
27use rand::Rng;
28use sdl3::pixels::Color;
29use sdl3::render::{Canvas, FPoint};
30use sdl3::video::Window;
31
32/// Parameters controlling the fire effect appearance
33#[derive(Debug, Clone, Copy)]
34pub struct FireParams {
35    /// Height of the flames above the line (in pixels)
36    pub flame_height: f32,
37    /// Intensity of the fire (0.0 to 1.0)
38    pub intensity: f32,
39    /// Speed of flickering animation
40    pub flicker_speed: f32,
41    /// Density of flame particles
42    pub particle_density: f32,
43    /// Width of the flame effect
44    pub flame_width: f32,
45}
46
47impl Default for FireParams {
48    fn default() -> Self {
49        Self {
50            flame_height: 50.0,
51            intensity: 1.0,
52            flicker_speed: 2.0,
53            particle_density: 0.5,
54            flame_width: 20.0,
55        }
56    }
57}
58
59/// Represents a line that will be rendered with fire effect
60#[derive(Debug, Clone, Copy)]
61pub struct FireLine {
62    pub start: FPoint,
63    pub end: FPoint,
64    pub params: FireParams,
65}
66
67impl FireLine {
68    /// Create a new fire line from coordinates and parameters
69    pub fn new(x1: f32, y1: f32, x2: f32, y2: f32, params: FireParams) -> Self {
70        Self {
71            start: FPoint::new(x1, y1),
72            end: FPoint::new(x2, y2),
73            params,
74        }
75    }
76
77    /// Get the length of the line
78    pub fn length(&self) -> f32 {
79        let dx = self.end.x - self.start.x;
80        let dy = self.end.y - self.start.y;
81        (dx * dx + dy * dy).sqrt()
82    }
83}
84
85/// Renderer for fire lines using SDL3
86pub struct FireRenderer {
87    canvas: Canvas<Window>,
88    noise: Perlin,
89}
90
91impl FireRenderer {
92    /// Create a new fire renderer with the given canvas
93    pub fn new(canvas: Canvas<Window>) -> Self {
94        Self {
95            canvas,
96            noise: Perlin::new(rand::rng().random()),
97        }
98    }
99
100    /// Get a mutable reference to the canvas
101    pub fn canvas_mut(&mut self) -> &mut Canvas<Window> {
102        &mut self.canvas
103    }
104
105    /// Present the rendered frame
106    pub fn present(&mut self) {
107        self.canvas.present();
108    }
109
110    /// Render a fire line at the given time
111    pub fn render(&mut self, line: &FireLine, time: f32) -> Result<(), String> {
112        let length = line.length();
113        let num_samples = (length * line.params.particle_density) as usize;
114
115        if num_samples == 0 {
116            return Ok(());
117        }
118
119        for i in 0..num_samples {
120            let t = i as f32 / num_samples as f32;
121
122            // Interpolate along the line
123            let x = line.start.x + t * (line.end.x - line.start.x);
124            let base_y = line.start.y + t * (line.end.y - line.start.y);
125
126            // Generate flame particles above this point
127            let particles_per_sample = (line.params.flame_height * 0.5) as usize;
128
129            for p in 0..particles_per_sample {
130                let y_offset = p as f32;
131                let y = base_y - y_offset;
132
133                // Add horizontal spread
134                let x_offset = (rand::rng().random::<f32>() - 0.5) * line.params.flame_width;
135                let px = x + x_offset;
136
137                // Sample noise for this particle
138                let noise_val = self.noise.get([
139                    px as f64 * 0.05,
140                    y as f64 * 0.05,
141                    (time * line.params.flicker_speed) as f64,
142                ]);
143
144                // Normalize noise to 0..1
145                let noise_normalized = ((noise_val + 1.0) * 0.5).clamp(0.0, 1.0) as f32;
146
147                // Height factor: flames fade as they rise
148                let height_factor = 1.0 - (y_offset / line.params.flame_height);
149
150                // Combine factors for final intensity
151                let intensity = noise_normalized * height_factor * line.params.intensity;
152
153                // Skip transparent pixels
154                if intensity < 0.1 {
155                    continue;
156                }
157
158                // Map intensity to fire color gradient
159                let color = fire_color(intensity);
160
161                self.canvas.set_draw_color(color);
162                self.canvas.draw_point(FPoint::new(px, y)).map_err(|e| e.to_string())?;
163            }
164        }
165
166        Ok(())
167    }
168}
169
170/// Maps intensity (0.0 to 1.0) to fire colors
171/// black -> red -> orange -> yellow -> white
172fn fire_color(intensity: f32) -> Color {
173    let intensity = intensity.clamp(0.0, 1.0);
174
175    if intensity < 0.25 {
176        // black to dark red
177        let t = intensity / 0.25;
178        Color::RGB((t * 139.0) as u8, 0, 0)
179    } else if intensity < 0.5 {
180        // dark red to red
181        let t = (intensity - 0.25) / 0.25;
182        Color::RGB((139.0 + t * 116.0) as u8, 0, 0)
183    } else if intensity < 0.75 {
184        // red to orange
185        let t = (intensity - 0.5) / 0.25;
186        Color::RGB(255, (t * 165.0) as u8, 0)
187    } else if intensity < 0.9 {
188        // orange to yellow
189        let t = (intensity - 0.75) / 0.15;
190        Color::RGB(255, (165.0 + t * 90.0) as u8, (t * 255.0) as u8)
191    } else {
192        // yellow to white
193        let t = (intensity - 0.9) / 0.1;
194        Color::RGB(255, 255, (255.0 + t * 0.0) as u8)
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn test_fire_line_creation() {
204        let line = FireLine::new(0.0, 0.0, 100.0, 0.0, FireParams::default());
205        assert_eq!(line.start.x(), 0.0);
206        assert_eq!(line.start.y(), 0.0);
207        assert_eq!(line.end.x(), 100.0);
208        assert_eq!(line.end.y(), 0.0);
209    }
210
211    #[test]
212    fn test_fire_line_length() {
213        let line = FireLine::new(0.0, 0.0, 3.0, 4.0, FireParams::default());
214        assert!((line.length() - 5.0).abs() < 0.001);
215    }
216
217    #[test]
218    fn test_fire_color_gradient() {
219        let black = fire_color(0.0);
220        let red = fire_color(0.5);
221        let orange = fire_color(0.7);
222        let yellow = fire_color(0.85);
223        let white = fire_color(1.0);
224
225        // Just verify they're different and progressively brighter
226        assert!(black.r < red.r);
227        assert!(red.g < orange.g);
228        assert!(orange.b < yellow.b);
229    }
230}