//! Drawing space: starfields, lit planets, rocks, stations, wrecks and your //! ship, in terminal cells. //! //! A local map unit is one cell across and half a cell down (cells are //! about twice as tall as wide), so a circle of radius r spans 2r columns //! and r rows, and looks round. use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier}; use crate::names::Culture; use crate::seed; use crate::space::Vec2; use crate::space::flight::Ship; use crate::space::system::Rgb; pub fn rgb((r, g, b): Rgb) -> Color { Color::Rgb(r, g, b) } /// `colour` at `k` times its brightness (k from 0 to about 1.3). pub fn shade((r, g, b): Rgb, k: f64) -> Color { let f = |c: u8| (f64::from(c) * k).round().clamp(0.0, 255.0) as u8; Color::Rgb(f(r), f(g), f(b)) } /// Between `a` (at k = 0) and `b` (at k = 1). pub fn mix(a: Rgb, b: Rgb, k: f64) -> Rgb { let k = k.clamp(0.0, 1.0); let f = |x: u8, y: u8| (f64::from(x) * (1.0 - k) + f64::from(y) * k).round() as u8; (f(a.0, b.0), f(a.1, b.1), f(a.2, b.2)) } /// A number from 0 to 1 that's always the same for the same inputs. pub fn hash01(seed: u64, x: i64, y: i64) -> f64 { let h = seed::derive(seed, "", (x as u64) << 32 ^ (y as u64 & 0xffff_ffff)); (h >> 11) as f64 / (1u64 << 53) as f64 } /// Where on screen a map position lands, with the camera in the middle of /// `area`. Fractional: the cell is the floor of each coordinate. #[derive(Clone, Copy)] pub struct View { pub area: Rect, pub camera: Vec2, } impl View { pub fn screen(&self, p: Vec2) -> (f64, f64) { let a = self.area; let col = f64::from(a.x) + f64::from(a.width) / 2.0 + (p.x - self.camera.x); let row = f64::from(a.y) + f64::from(a.height) / 2.0 + (p.y - self.camera.y) / 2.0; (col, row) } /// The cell a map position falls in, if it's on screen. pub fn cell(&self, p: Vec2) -> Option<(u16, u16)> { let (col, row) = self.screen(p); cell_in(self.area, col.floor(), row.floor()) } pub fn put(&self, buf: &mut Buffer, p: Vec2, ch: char, fg: Color) { if let Some(pos) = self.cell(p) { buf[pos].set_char(ch).set_fg(fg); } } } fn cell_in(area: Rect, col: f64, row: f64) -> Option<(u16, u16)> { let inside = col >= f64::from(area.x) && row >= f64::from(area.y) && col < f64::from(area.right()) && row < f64::from(area.bottom()); inside.then_some((col as u16, row as u16)) } /// Writes `text` centred on a screen column, clipped to `area`. pub fn label(buf: &mut Buffer, area: Rect, col: f64, row: f64, text: &str, fg: Color) { let width = text.chars().count() as f64; let start = (col - width / 2.0).floor(); for (i, ch) in text.chars().enumerate() { if let Some(pos) = cell_in(area, start + i as f64, row.floor()) { buf[pos].set_char(ch).set_fg(fg); } } } /// Star layers: how fast each moves as the camera does, how rare its stars /// are, glyph, and colour. Slower layers read as further away. const STAR_LAYERS: [(f64, u32, char, Rgb); 3] = [ (0.04, 90, '.', (70, 72, 92)), (0.12, 170, '·', (130, 132, 150)), (0.3, 420, '+', (210, 210, 225)), ]; pub fn starfield(buf: &mut Buffer, area: Rect, camera: Vec2, seed: u64) { for (layer, &(parallax, rarity, glyph, colour)) in STAR_LAYERS.iter().enumerate() { let layer_seed = seed::derive(seed, "stars", layer as u64); let dx = (camera.x * parallax).floor() as i64; let dy = (camera.y * parallax / 2.0).floor() as i64; for row in area.top()..area.bottom() { for col in area.left()..area.right() { let h = hash01(layer_seed, i64::from(col) + dx, i64::from(row) + dy); if h * f64::from(rarity) < 1.0 { buf[(col, row)].set_char(glyph).set_fg(rgb(colour)); } } } } } /// How to paint a sphere. pub struct Surface { pub rgb: Rgb, /// Cloud bands, for giants. pub banded: bool, /// City lights on the night side. pub lights: bool, /// Glyphs from darkest to brightest. pub ramp: &'static [char], /// Varies the mottling and bands from one body to the next. pub seed: u64, } pub const PLANET_RAMP: &[char] = &['·', '░', '▒', '▓', '█']; pub const ROCK_RAMP: &[char] = &['.', ':', '%', '#', '@']; /// A sphere centred at screen position (`col`, `row`), `radius` columns /// across, lit from `sun` (a unit vector on the map, y down). Returns /// whether it covered any cell. pub fn sphere( buf: &mut Buffer, area: Rect, (col, row): (f64, f64), radius: f64, sun: Vec2, surface: &Surface, ) -> bool { // Mostly from the side, a little from in front, so there's always a // terminator to see. let light = { let (x, y, z) = (sun.x, sun.y, 0.45); let len = (x * x + y * y + z * z).sqrt(); (x / len, y / len, z / len) }; let top = (row - radius / 2.0).floor() as i64; let bottom = (row + radius / 2.0).ceil() as i64; let left = (col - radius).floor() as i64; let right = (col + radius).ceil() as i64; let mut drawn = false; for r in top..=bottom { for c in left..=right { let nx = (c as f64 + 0.5 - col) / radius; let ny = (r as f64 + 0.5 - row) * 2.0 / radius; let d2 = nx * nx + ny * ny; if d2 > 1.0 { continue; } let Some(pos) = cell_in(area, c as f64, r as f64) else { continue; }; drawn = true; let nz = (1.0 - d2).sqrt(); let lit = (nx * light.0 + ny * light.1 + nz * light.2).max(0.0); let grain = |n: f64| { let g = hash01(surface.seed, (nx * n).floor() as i64, (ny * n).floor() as i64); 0.86 + 0.14 * g }; let mut tone = grain(5.0); if surface.banded { let phase = (surface.seed % 628) as f64 / 100.0; tone = 0.84 + 0.12 * (ny * 7.0 + phase).sin() + 0.06 * (ny * 19.0 + phase * 2.0).sin(); } let cell = &mut buf[pos]; if lit < 0.04 { let city = surface.lights && grain(13.0) > 0.99 - 0.08; if city { cell.set_char('·').set_fg(Color::Rgb(255, 196, 110)); } else { cell.set_char(surface.ramp[0]).set_fg(shade(surface.rgb, 0.16 * tone)); } continue; } let steps = surface.ramp.len() - 1; let glyph = surface.ramp[1 + ((lit * steps as f64) as usize).min(steps - 1)]; cell.set_char(glyph).set_fg(shade(surface.rgb, (0.2 + 0.95 * lit) * tone)); } } drawn } /// One glyph per eighth of a turn, for headings (0 is up, clockwise). pub fn arrow(heading: f64) -> char { const ARROWS: [char; 8] = ['↑', '↗', '→', '↘', '↓', '↙', '←', '↖']; let eighth = (heading / std::f64::consts::TAU * 8.0).round() as i64; ARROWS[eighth.rem_euclid(8) as usize] } /// Each culture's station, drawn centred on its position. pub fn station_art(culture: Culture) -> (&'static [&'static str], Rgb) { match culture { Culture::Qaari => (&[" .-=-. ", "=(( o ))=", " '-=-' "], (232, 190, 112)), Culture::Cant => (&[" /\\ ", "=[|##|]=", " |##| ", " \\/ "], (204, 124, 92)), Culture::Houses => (&[" _/^^\\_ ", " <|-<>-|>", " \\__/ "], (206, 186, 240)), Culture::Drift => (&[" =o= =o= ", "<==[ ]==>", " =o= =o= "], (124, 212, 200)), } } pub fn draw_station(buf: &mut Buffer, view: &View, culture: Culture, ticks: u64) { let (art, colour) = station_art(culture); let (col, row) = view.screen(Vec2::ZERO); let top = row - art.len() as f64 / 2.0; for (i, line) in art.iter().enumerate() { let width = line.chars().count() as f64; let start = (col - width / 2.0).floor(); for (j, ch) in line.chars().enumerate() { if ch == ' ' { continue; } let Some(pos) = cell_in(view.area, start + j as f64, (top + i as f64).floor()) else { continue; }; // Running lights blink in turn. let light = ch == 'o' && (ticks / 15 + j as u64).is_multiple_of(3); let fg = if light { Color::Rgb(255, 240, 200) } else { rgb(colour) }; buf[pos].set_char(ch).set_fg(fg); if light { buf[pos].modifier.insert(Modifier::BOLD); } } } } /// A wreck: a dark hull pointing along its heading, with a red distress /// beacon blinking if `beacon` (there's still anything aboard, and the /// beacon hasn't failed). pub fn draw_wreck( buf: &mut Buffer, view: &View, pos: Vec2, heading: f64, beacon: bool, ticks: u64, ) { let along = Vec2::from_heading(heading); let hull = Color::Rgb(112, 118, 140); view.put(buf, pos - along * 1.5, '=', hull); view.put(buf, pos, '#', hull); view.put(buf, pos + along * 1.5, arrow(heading), hull); if beacon && ticks % 40 < 8 { view.put(buf, pos, '#', Color::Rgb(255, 70, 60)); } } /// Your ship's outline, a notched dart: corners as (ahead, to the right) /// of its position, in map units. About five and a half units long. const HULL: [(f64, f64); 4] = [(2.75, 0.0), (-2.75, 2.1), (-0.75, 0.0), (-2.75, -2.1)]; /// Where the engine flame starts: the notch at the back of the hull. const NOZZLE: f64 = -0.75; /// Braille dot bits by (column, row) within a cell. const BRAILLE: [[u32; 4]; 2] = [[0x01, 0x02, 0x04, 0x40], [0x08, 0x10, 0x20, 0x80]]; /// Your ship, turned to exactly its heading, with a flame out the back /// while the engine is lit. Drawn in braille dots, which are half a unit /// square (two across a cell, four down), so it turns smoothly rather than /// snapping between eight arrows. pub fn draw_ship(buf: &mut Buffer, view: &View, ship: &Ship, ticks: u64) { let ahead = Vec2::from_heading(ship.heading); let right = Vec2::new(-ahead.y, ahead.x); let local = |p: Vec2| (p.dot(ahead), p.dot(right)); let (col, row) = view.screen(ship.pos); if ship.thrusting { // A plume, widest just behind the nozzle, flickering between short // and long ten times a second. Its first unit or two is mostly // hidden under the hull, which reads better than filling the notch. let length = 4.0 + 2.0 * hash01(0, (ticks / 3) as i64, 0); let flame = [ (NOZZLE, 0.5), (NOZZLE - 1.5, 1.0), (NOZZLE - length, 0.0), (NOZZLE - 1.5, -1.0), (NOZZLE, -0.5), ]; for ((c, r), ch) in braille(view, ship.pos, 7.0, |p| inside(&flame, local(p))) { // Yellow near the ship, cooling to red-orange at the tip. let middle = Vec2::new(f64::from(c) + 0.5 - col, (f64::from(r) + 0.5 - row) * 2.0); let k = (NOZZLE - local(middle).0) / length; let colour = mix((255, 224, 130), (240, 80, 30), (k - 0.3) / 0.7); buf[(c, r)].set_char(ch).set_fg(rgb(colour)); } } // The hull goes on top: where the two share a cell, the hull wins. for (cell, ch) in braille(view, ship.pos, 3.5, |p| inside(&HULL, local(p))) { buf[cell].set_char(ch).set_fg(Color::White); } } /// The braille characters for a shape within `reach` map units of `pos`, /// and the cells they go in. `covers` says whether a point (in map units /// from `pos`) is inside the shape, and a dot is lit when the shape covers /// at least 40% of it, which keeps edges clean at every angle. fn braille( view: &View, pos: Vec2, reach: f64, covers: impl Fn(Vec2) -> bool, ) -> Vec<((u16, u16), char)> { // Samples per dot, each way. const SAMPLES: u32 = 4; let sub = |i: u32| (f64::from(i) + 0.5) / f64::from(SAMPLES); let (col, row) = view.screen(pos); let mut cells = Vec::new(); for r in (row - reach / 2.0).floor() as i64..=(row + reach / 2.0).floor() as i64 { for c in (col - reach).floor() as i64..=(col + reach).floor() as i64 { let Some(cell) = cell_in(view.area, c as f64, r as f64) else { continue; }; let mut bits = 0; for (dx, column) in BRAILLE.iter().enumerate() { for (dy, bit) in column.iter().enumerate() { let mut hits = 0; for sx in 0..SAMPLES { for sy in 0..SAMPLES { // In map units from `pos`: a column is one, a row two. let x = c as f64 - col + (dx as f64 + sub(sx)) / 2.0; let y = (r as f64 - row + (dy as f64 + sub(sy)) / 4.0) * 2.0; hits += u32::from(covers(Vec2::new(x, y))); } } if hits * 5 >= SAMPLES * SAMPLES * 2 { bits |= bit; } } } if let Some(ch) = char::from_u32(0x2800 + bits).filter(|_| bits != 0) { cells.push((cell, ch)); } } } cells } /// Whether `point` is inside `polygon` (even-odd rule, so it may be concave). fn inside(polygon: &[(f64, f64)], (x, y): (f64, f64)) -> bool { let mut odd = false; for (i, &(x1, y1)) in polygon.iter().enumerate() { let (x2, y2) = polygon[(i + 1) % polygon.len()]; if (y1 > y) != (y2 > y) && x < x1 + (y - y1) * (x2 - x1) / (y2 - y1) { odd = !odd; } } odd } #[cfg(test)] mod tests { use super::*; /// Every lit braille dot in `buf`, in map units from the centre of /// `view`, as (ahead, to the right) for a ship facing `heading`. fn ship_dots(buf: &Buffer, view: &View, heading: f64) -> Vec<(f64, f64)> { let ahead = Vec2::from_heading(heading); let right = Vec2::new(-ahead.y, ahead.x); let (col, row) = view.screen(view.camera); let mut dots = Vec::new(); for r in 0..view.area.height { for c in 0..view.area.width { let ch = buf[(c, r)].symbol().chars().next().unwrap_or(' '); let bits = u32::from(ch).wrapping_sub(0x2800); for (dx, column) in BRAILLE.iter().enumerate() { for (dy, &bit) in column.iter().enumerate() { if bits <= 0xff && bits & bit != 0 { let x = f64::from(c) - col + (dx as f64 + 0.5) / 2.0; let y = (f64::from(r) - row + (dy as f64 + 0.5) / 4.0) * 2.0; let p = Vec2::new(x, y); dots.push((p.dot(ahead), p.dot(right))); } } } } } dots } fn ship(heading: f64, thrusting: bool) -> Ship { Ship { pos: Vec2::ZERO, vel: Vec2::ZERO, heading, thrusting } } #[test] fn the_ship_points_its_nose_along_its_heading() { let view = View { area: Rect::new(0, 0, 20, 10), camera: Vec2::ZERO }; for heading in [0.0, 1.0, 1.9, 2.5, 3.6, 4.4, 5.5] { let mut buf = Buffer::empty(view.area); draw_ship(&mut buf, &view, &ship(heading, false), 0); let dots = ship_dots(&buf, &view, heading); assert!((24..=36).contains(&dots.len()), "{heading}: {} dots", dots.len()); let front = dots.iter().filter(|d| d.0 > 1.25); assert!(front.clone().count() >= 2, "{heading}: a nose"); assert!(front.clone().all(|d| d.1.abs() < 1.25), "{heading}: a narrow nose"); let wing = |side: f64| dots.iter().any(|d| d.0 < -1.5 && d.1 * side > 1.25); assert!(wing(1.0) && wing(-1.0), "{heading}: wings behind, either side"); } } #[test] fn a_lit_engine_trails_a_flame_out_the_back() { let view = View { area: Rect::new(0, 0, 24, 12), camera: Vec2::ZERO }; for heading in [0.0, 2.0, 4.0] { // The flame's dots, as how far ahead of the ship each one is. let flame = |thrusting: bool, ticks: u64| { let mut buf = Buffer::empty(view.area); draw_ship(&mut buf, &view, &ship(heading, thrusting), ticks); for cell in buf.content.iter_mut().filter(|c| c.fg == Color::White) { cell.reset(); } ship_dots(&buf, &view, heading) }; assert!(flame(false, 0).is_empty(), "{heading}: no flame with the engine off"); let lit = flame(true, 0); assert!(!lit.is_empty(), "{heading}: a flame with it on"); assert!(lit.iter().all(|d| d.0 < -1.0), "{heading}: only behind: {lit:?}"); let sizes: Vec = (0..60).step_by(3).map(|t| flame(true, t).len()).collect(); assert!(sizes.iter().min() < sizes.iter().max(), "{heading}: flickering"); } } #[test] fn arrows_point_where_headings_do() { use std::f64::consts::TAU; assert_eq!(arrow(0.0), '↑'); assert_eq!(arrow(TAU / 4.0), '→'); assert_eq!(arrow(TAU - 0.1), '↑'); assert_eq!(arrow(-TAU / 8.0), '↖'); } #[test] fn spheres_are_round_and_lit_on_the_sunward_side() { let area = Rect::new(0, 0, 40, 20); let mut buf = Buffer::empty(area); let surface = Surface { rgb: (200, 200, 200), banded: false, lights: false, ramp: PLANET_RAMP, seed: 1, }; assert!(sphere(&mut buf, area, (20.0, 10.0), 8.0, Vec2::new(1.0, 0.0), &surface)); let row: String = (0..40).map(|c| buf[(c, 10)].symbol().chars().next().unwrap()).collect(); let covered = row.chars().filter(|&c| c != ' ').count(); assert!((15..=17).contains(&covered), "8 units across each way: {row:?}"); let column: usize = (0..20).filter(|&r| buf[(20, r)].symbol() != " ").count(); assert!((7..=9).contains(&column), "and half that many rows tall"); assert_eq!(buf[(26, 10)].symbol(), "█", "bright to the east, where the sun is"); assert_eq!(buf[(13, 10)].symbol(), "·", "dark to the west"); } }