summaryrefslogtreecommitdiff
path: root/src/space/locale.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/space/locale.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/space/locale.rs')
-rw-r--r--src/space/locale.rs406
1 files changed, 406 insertions, 0 deletions
diff --git a/src/space/locale.rs b/src/space/locale.rs
new file mode 100644
index 0000000..d6339b9
--- /dev/null
+++ b/src/space/locale.rs
@@ -0,0 +1,406 @@
+//! The local map of one place: what you fly around in once you arrive.
+//!
+//! A planet's orbit has the planet filling the middle of the map, a few
+//! rocks circling it, and its moons hanging far off in the sky. A moon is
+//! the same, smaller, with its planet looming behind. The belt is all rocks.
+//! A station sits among the Trojan asteroids that collect at its Lagrange
+//! point.
+//!
+//! The layout (which way things lie) is fixed for each place. The rocks are
+//! regenerated for each day you visit, so a mined-out place fills up again
+//! if you come back another day. The wrecks are whichever are calling that
+//! day, and any that were calling when you set out for the place, less
+//! those you've stripped: a hulk you've emptied is still there while you
+//! are, and gone when you come back. Each wreck keeps its own spot for as
+//! long as it lasts. The star lights everything from the direction it
+//! really is in on that date.
+
+use std::collections::HashSet;
+use std::f64::consts::TAU;
+
+use rand::RngExt;
+use rand::seq::IndexedRandom;
+
+use super::Vec2;
+use super::system::{BodyKind, PlaceKind, Rgb, System, Wreck};
+use crate::names::{self, Culture};
+use crate::seed::{self, Rng};
+
+#[derive(Debug, PartialEq)]
+pub struct Ore {
+ pub name: &'static str,
+ /// Scrip per ton, before local prices.
+ pub value: u32,
+ pub rgb: Rgb,
+ weight: u32,
+}
+
+#[rustfmt::skip]
+pub const ORES: &[Ore] = &[
+ Ore { name: "iron ore", value: 4, rgb: (178, 112, 88), weight: 10 },
+ Ore { name: "nickel", value: 6, rgb: (176, 182, 160), weight: 7 },
+ Ore { name: "iridium", value: 14, rgb: (200, 214, 236), weight: 4 },
+ Ore { name: "saltglass", value: 22, rgb: (240, 222, 196), weight: 2 },
+ Ore { name: "vesper crystal", value: 35, rgb: (196, 124, 255), weight: 1 },
+];
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Size {
+ Small,
+ Medium,
+ Large,
+}
+
+impl Size {
+ pub fn radius(self) -> f64 {
+ match self {
+ Size::Small => 0.8,
+ Size::Medium => 1.6,
+ Size::Large => 2.6,
+ }
+ }
+
+ /// Seconds of laser fire to break it.
+ pub fn toughness(self) -> f64 {
+ match self {
+ Size::Small => 0.4,
+ Size::Medium => 1.0,
+ Size::Large => 2.0,
+ }
+ }
+
+ /// What it breaks into, if anything.
+ pub fn smaller(self) -> Option<Size> {
+ match self {
+ Size::Small => None,
+ Size::Medium => Some(Size::Small),
+ Size::Large => Some(Size::Medium),
+ }
+ }
+}
+
+pub struct Rock {
+ pub pos: Vec2,
+ /// Drift in a straight line, in units per second.
+ pub vel: Vec2,
+ /// Turning around the middle of the map, in radians per second: rocks in
+ /// a planet's orbit circle it rather than drifting through it.
+ pub circling: f64,
+ pub size: Size,
+ pub ore: Option<&'static Ore>,
+ /// Seconds of laser fire left before it breaks.
+ pub integrity: f64,
+}
+
+impl Rock {
+ pub fn new(pos: Vec2, vel: Vec2, circling: f64, size: Size, ore: Option<&'static Ore>) -> Rock {
+ Rock { pos, vel, circling, size, ore, integrity: size.toughness() }
+ }
+
+ pub fn velocity(&self) -> Vec2 {
+ self.vel + Vec2::new(-self.pos.y, self.pos.x) * self.circling
+ }
+
+ pub fn step(&mut self, dt: f64, extent: f64) {
+ self.pos = self.pos.rotated(self.circling * dt) + self.vel * dt;
+ // Drifters that leave one side come back in the other.
+ let wrap = |v: f64| (v + extent).rem_euclid(2.0 * extent) - extent;
+ self.pos = Vec2::new(wrap(self.pos.x), wrap(self.pos.y));
+ }
+}
+
+/// The planet or moon in the middle of the map. It sits at the origin.
+pub struct Body {
+ pub name: String,
+ pub kind: &'static BodyKind,
+ pub radius: f64,
+ /// Whether anyone lives there to trade with.
+ pub settled: bool,
+}
+
+/// The station in the middle of the map. It sits at the origin.
+pub struct Dock {
+ pub name: String,
+ pub culture: Culture,
+}
+
+/// How close to the middle of a station you must be to dock.
+pub const DOCK_RADIUS: f64 = 5.0;
+
+/// Something far off in the sky: a moon seen from its planet, or the
+/// planet behind a moon. You can't fly to it here; it's scenery that barely
+/// moves as you do.
+pub struct Backdrop {
+ pub name: String,
+ pub kind: &'static BodyKind,
+ /// Where it hangs, relative to the middle of the screen when you're at
+ /// the middle of the map.
+ pub pos: Vec2,
+ pub radius: f64,
+}
+
+/// How much backdrops move as you fly: a tenth as fast as the map.
+pub const BACKDROP_PARALLAX: f64 = 0.1;
+
+pub struct LocalWreck {
+ pub wreck: Wreck,
+ pub pos: Vec2,
+ /// Which way its bow points, for drawing.
+ pub heading: f64,
+}
+
+pub struct Locale {
+ pub place: usize,
+ pub name: String,
+ /// Where you are, as you'd say it: "in orbit around Qeth, a desert world".
+ pub what: String,
+ /// Half the width of the map. Past this the edge pushes you back, and
+ /// drifting rocks wrap round.
+ pub extent: f64,
+ /// Unit vector towards the star, on the map (y down).
+ pub sun: Vec2,
+ pub body: Option<Body>,
+ pub dock: Option<Dock>,
+ pub backdrops: Vec<Backdrop>,
+ pub rocks: Vec<Rock>,
+ pub wrecks: Vec<LocalWreck>,
+ /// Where you come in.
+ pub arrival: Vec2,
+}
+
+impl Locale {
+ /// What you'd crash the laser into, or land on: the body or the station,
+ /// as a circle at the origin.
+ pub fn solid_radius(&self) -> Option<f64> {
+ self.body.as_ref().map(|b| b.radius).or(self.dock.as_ref().map(|_| DOCK_RADIUS - 2.0))
+ }
+}
+
+/// From system coordinates (y up) to map coordinates (y down).
+fn to_map(v: Vec2) -> Vec2 {
+ Vec2::new(v.x, -v.y)
+}
+
+/// The map of `place` on `day`, having set out for it on `departed`,
+/// without the wrecks you've `stripped`.
+pub fn generate(
+ system: &System,
+ place: usize,
+ day: f64,
+ departed: Option<f64>,
+ stripped: &HashSet<u64>,
+) -> Locale {
+ let layout_seed = seed::derive(system.seed, "place", place as u64);
+ let mut rng = seed::rng(layout_seed);
+ let mut rocks_rng = seed::rng(seed::derive(layout_seed, "rocks", day.floor() as u64));
+
+ let here = system.position(place, day);
+ let sun = to_map(-here).normalised();
+ let name = system.places[place].name.clone();
+
+ let mut body = None;
+ let mut dock = None;
+ let mut backdrops = Vec::new();
+ let mut rocks = Vec::new();
+ let (what, extent);
+ match system.places[place].kind {
+ PlaceKind::Planet(p) => {
+ let planet = &system.planets[p];
+ what = format!("in orbit around {}, {}", planet.name, names::a(planet.kind.name));
+ let radius = planet.kind.size;
+ extent = 90.0 + radius * 2.0;
+ body = Some(Body {
+ name: planet.name.clone(),
+ kind: planet.kind,
+ radius,
+ settled: planet.people.is_some(),
+ });
+ for (m, moon) in planet.moons.iter().enumerate() {
+ let toward = to_map(moon.orbit.position(day)).normalised();
+ backdrops.push(Backdrop {
+ name: moon.name.clone(),
+ kind: moon.kind,
+ pos: toward * (40.0 + 12.0 * m as f64),
+ radius: moon.kind.size * 0.25,
+ });
+ }
+ let count = if planet.kind.giant {
+ rocks_rng.random_range(14..=24)
+ } else {
+ rocks_rng.random_range(2..=8)
+ };
+ ring(&mut rocks, &mut rocks_rng, count, radius + 8.0, radius + 30.0, 0.3);
+ }
+ PlaceKind::Moon(p, m) => {
+ let planet = &system.planets[p];
+ let moon = &planet.moons[m];
+ what = format!("in orbit around {}, a moon of {}", moon.name, planet.name);
+ let radius = moon.kind.size * 0.6;
+ extent = 80.0;
+ body = Some(Body {
+ name: moon.name.clone(),
+ kind: moon.kind,
+ radius,
+ settled: moon.people.is_some(),
+ });
+ backdrops.push(Backdrop {
+ name: planet.name.clone(),
+ kind: planet.kind,
+ pos: to_map(-moon.orbit.position(day)).normalised() * (36.0 + planet.kind.size),
+ radius: planet.kind.size * 1.1,
+ });
+ let count = rocks_rng.random_range(1..=5);
+ ring(&mut rocks, &mut rocks_rng, count, radius + 6.0, radius + 22.0, 0.3);
+ }
+ PlaceKind::Belt => {
+ let belt = &system.belt;
+ what = format!("among the rocks of {}", belt.name);
+ // A denser field spreads wider too, so it's thicker without
+ // being packed solid.
+ extent = 140.0 * belt.density.sqrt().max(1.0);
+ // The whole field drifts one way, each rock a little differently.
+ let flow = Vec2::from_heading(rng.random_range(0.0..TAU)) * rng.random_range(0.6..1.6);
+ let many = |n: f64| (n * belt.density).round() as i32;
+ for _ in 0..rocks_rng.random_range(many(70.0)..=many(100.0)) {
+ let pos = loop {
+ let pos = random_point(&mut rocks_rng, extent);
+ if pos.length() > 14.0 {
+ break pos;
+ }
+ };
+ let wobble = Vec2::from_heading(rocks_rng.random_range(0.0..TAU)) * 0.4;
+ let (size, ore) = random_rock(&mut rocks_rng, belt.richness);
+ rocks.push(Rock::new(pos, flow + wobble, 0.0, size, ore));
+ }
+ }
+ PlaceKind::Station(s) => {
+ let station = &system.stations[s];
+ let planet = &system.planets[station.planet];
+ let point = if station.leading { "L4" } else { "L5" };
+ what = format!("by {}, at {}'s {point} point", station.name, planet.name);
+ extent = 90.0;
+ dock = Some(Dock { name: station.name.clone(), culture: station.culture });
+ let toward_planet = planet.orbit.position(day) - here;
+ backdrops.push(Backdrop {
+ name: planet.name.clone(),
+ kind: planet.kind,
+ pos: to_map(toward_planet).normalised() * 40.0,
+ radius: planet.kind.size * 0.15,
+ });
+ // Trojans: a loose swarm, sharing the point with the station.
+ for _ in 0..rocks_rng.random_range(25..=40) {
+ let pos = loop {
+ let pos = random_point(&mut rocks_rng, extent);
+ if pos.length() > 18.0 {
+ break pos;
+ }
+ };
+ let vel = Vec2::from_heading(rocks_rng.random_range(0.0..TAU)) * 0.3;
+ let (size, ore) = random_rock(&mut rocks_rng, 0.45);
+ rocks.push(Rock::new(pos, vel, 0.0, size, ore));
+ }
+ }
+ }
+
+ let clear =
+ body.as_ref().map_or(0.0, |b| b.radius) + dock.as_ref().map_or(0.0, |_| DOCK_RADIUS);
+ let arrival = Vec2::new(0.0, (clear + 16.0).min(extent * 0.8));
+
+ let wrecks = system
+ .wrecks_at(place, day, departed, stripped)
+ .into_iter()
+ .map(|wreck| {
+ let mut rng = seed::rng(seed::derive(wreck.seed, "drift", place as u64));
+ let pos = loop {
+ let pos = random_point(&mut rng, extent * 0.7);
+ if pos.length() > clear + 12.0 && pos.distance(arrival) > 10.0 {
+ break pos;
+ }
+ };
+ LocalWreck { wreck, pos, heading: rng.random_range(0.0..TAU) }
+ })
+ .collect();
+
+ Locale { place, name, what, extent, sun, body, dock, backdrops, rocks, wrecks, arrival }
+}
+
+/// Rocks circling the middle of the map between two radii, all the same way.
+fn ring(rocks: &mut Vec<Rock>, rng: &mut Rng, count: usize, inner: f64, outer: f64, ore: f64) {
+ let way = if rng.random_bool(0.5) { 1.0 } else { -1.0 };
+ for _ in 0..count {
+ let r = rng.random_range(inner..outer);
+ let pos = Vec2::from_heading(rng.random_range(0.0..TAU)) * r;
+ // Further out goes round more slowly, as in a real orbit.
+ let circling = way * 4.0 / r.powf(1.5) * rng.random_range(0.9..1.1);
+ let (size, ore) = random_rock(rng, ore);
+ rocks.push(Rock::new(pos, Vec2::ZERO, circling, size, ore));
+ }
+}
+
+fn random_rock(rng: &mut Rng, ore_chance: f64) -> (Size, Option<&'static Ore>) {
+ let size = *[Size::Small, Size::Medium, Size::Medium, Size::Large].choose(rng).unwrap();
+ let ore = rng.random_bool(ore_chance).then(|| ORES.choose_weighted(rng, |o| o.weight).unwrap());
+ (size, ore)
+}
+
+fn random_point(rng: &mut Rng, extent: f64) -> Vec2 {
+ Vec2::new(rng.random_range(-extent..extent), rng.random_range(-extent..extent))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::space::system;
+
+ #[test]
+ fn every_place_makes_a_map() {
+ for seed in 0..40 {
+ let s = system::generate(seed);
+ for place in 0..s.places.len() {
+ let l = generate(&s, place, 12.5, None, &HashSet::new());
+ assert!((l.sun.length() - 1.0).abs() < 1e-9);
+ assert!(l.arrival.x.abs() < l.extent && l.arrival.y.abs() < l.extent);
+ let solid = l.solid_radius().unwrap_or(0.0);
+ assert!(l.arrival.length() > solid + 5.0, "you arrive clear of what's there");
+ for r in &l.rocks {
+ assert!(r.pos.length() > solid, "{}: a rock inside the middle", l.name);
+ }
+ let here = s.wrecks_on(12.5).iter().filter(|w| w.place == place).count();
+ assert_eq!(l.wrecks.len(), here);
+ }
+ }
+ }
+
+ #[test]
+ fn the_layout_stays_but_the_rocks_come_back() {
+ let s = system::generate(2);
+ let belt = s.places.iter().position(|p| p.kind == PlaceKind::Belt).unwrap();
+ let (a, b, c) = {
+ let none = HashSet::new();
+ (
+ generate(&s, belt, 3.2, None, &none),
+ generate(&s, belt, 3.7, None, &none),
+ generate(&s, belt, 9.0, None, &none),
+ )
+ };
+ assert_eq!(a.rocks.len(), b.rocks.len(), "same day, same rocks");
+ assert_eq!(a.rocks[0].pos, b.rocks[0].pos);
+ assert_ne!(a.rocks[0].pos, c.rocks[0].pos, "another day, new rocks");
+ }
+
+ #[test]
+ fn circling_rocks_keep_their_distance() {
+ let mut rock = Rock::new(Vec2::new(30.0, 0.0), Vec2::ZERO, 0.05, Size::Small, None);
+ for _ in 0..1000 {
+ rock.step(0.1, 100.0);
+ }
+ assert!((rock.pos.length() - 30.0).abs() < 1e-6);
+ }
+
+ #[test]
+ fn drifting_rocks_wrap_round() {
+ let mut rock = Rock::new(Vec2::new(99.0, 0.0), Vec2::new(5.0, 0.0), 0.0, Size::Small, None);
+ rock.step(1.0, 100.0);
+ assert!((rock.pos.x + 96.0).abs() < 1e-9);
+ }
+}