diff options
| author | skylar <me@casper> | 2026-11-11 15:24:37 -0800 |
|---|---|---|
| committer | skylar <me@casper> | 2026-11-11 15:24:37 -0800 |
| commit | 9ccf951473e21a8b90c25161156fa09a9b227a7b (patch) | |
| tree | e7bd9858a4cdbc5ae50069db8ca0cf868e436dec /src/scenes/sky.rs | |
| parent | d36c54d996f3c14ae811312dbbe621e12a39edde (diff) | |
Orrery 0.1.0: the galaxy, flight, cities, inventory and saves
Grows the derelict crawler into the whole game, and gets it ready to
publish.
- A galaxy of star systems joined by lanes, split between four peoples'
territories, with a star map and a system map. Systems are modelled
with Kepler orbits, moons inside their Hill spheres, belts past the
frost line and stations at Lagrange points.
- Flight around one place at a time: arcade flight with a throttle
fallback where held keys can't be detected, mining rocks for ore,
landing, docking at stations, and boarding wrecks that come and go by
the calendar.
- Your ship to walk about, and capital cities to land at: generated
plans with walls, quarters and streets, building interiors, shops,
taverns, scholars and armourers.
- Fighting with a weapon and a gun ready, six damage types and how each
monster takes them, and armour that guards, wears and can be mended.
- Everything you own has a weight: a vim-keyed inventory, a pack and the
ship's lockers, and trade in ore, salvage, arms and fuel.
- Characters with a people and a ship of their own, saved as versioned
JSON on every scene change and on quit, with a character sheet, a
saves list and a pause menu wherever you are.
- `orrery weapons`, `armour`, `galaxy`, `system` and `town` print the
generators' output without the TUI.
- Release: README install steps for macOS and Linux, a Makefile whose
install also works with macOS's install, an MIT licence, and Cargo
metadata with rust-version 1.88.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'src/scenes/sky.rs')
| -rw-r--r-- | src/scenes/sky.rs | 460 |
1 files changed, 460 insertions, 0 deletions
diff --git a/src/scenes/sky.rs b/src/scenes/sky.rs new file mode 100644 index 0000000..8248dd0 --- /dev/null +++ b/src/scenes/sky.rs @@ -0,0 +1,460 @@ +//! 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<usize> = (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"); + } +} |