summaryrefslogtreecommitdiff
path: root/src/scenes/sky.rs
diff options
context:
space:
mode:
authorskylar <me@casper>2026-11-11 15:24:37 -0800
committerskylar <me@casper>2026-11-11 15:24:37 -0800
commit9ccf951473e21a8b90c25161156fa09a9b227a7b (patch)
treee7bd9858a4cdbc5ae50069db8ca0cf868e436dec /src/scenes/sky.rs
parentd36c54d996f3c14ae811312dbbe621e12a39edde (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.rs460
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");
+ }
+}