From 9ccf951473e21a8b90c25161156fa09a9b227a7b Mon Sep 17 00:00:00 2001 From: skylar Date: Wed, 11 Nov 2026 15:24:37 -0800 Subject: 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 --- src/space/locale.rs | 406 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 406 insertions(+) create mode 100644 src/space/locale.rs (limited to 'src/space/locale.rs') 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 { + 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, + pub dock: Option, + pub backdrops: Vec, + pub rocks: Vec, + pub wrecks: Vec, + /// 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 { + 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, + stripped: &HashSet, +) -> 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, 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); + } +} -- cgit v1.2.3