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/flight.rs | 434 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 434 insertions(+) create mode 100644 src/space/flight.rs (limited to 'src/space/flight.rs') diff --git a/src/space/flight.rs b/src/space/flight.rs new file mode 100644 index 0000000..08f678d --- /dev/null +++ b/src/space/flight.rs @@ -0,0 +1,434 @@ +//! The rules of flight around one place: steering, thrust and drag, the +//! mining laser, breaking rocks, and scooping up the ore they drop. Nothing +//! here draws or reads keys; the scene turns keys into `Controls` and draws +//! the result. +//! +//! The feel is arcade rather than physics: you turn on the spot, thrust +//! pushes along your heading, a little drag stops you drifting forever, and +//! speed is capped. To stop, turn to face backwards (`Controls::retro` does +//! that for you) and thrust. Nothing pulls on you; planets and stations are +//! places to land or dock, not wells to fall into. + +use std::f64::consts::TAU; + +use rand::RngExt; + +use super::locale::{DOCK_RADIUS, Locale, Ore, Rock, Size}; +use super::{Vec2, turn_between}; +use crate::seed::{self, Rng}; + +/// Units per second per second at full thrust. +pub const THRUST: f64 = 26.0; +pub const MAX_SPEED: f64 = 32.0; +/// The fraction of your speed lost each second. +const DRAG: f64 = 0.12; +/// Radians per second. +pub const TURN_RATE: f64 = 3.6; +pub const LASER_RANGE: f64 = 24.0; +/// Seconds before uncollected ore drifts off for good. +pub const FLOTSAM_LIFE: f64 = 45.0; +const PICKUP_RANGE: f64 = 2.5; +/// Land, dock or board only when slower than this. +pub const LANDING_SPEED: f64 = 8.0; +/// Board a wreck only when this close. +pub const BOARDING_RANGE: f64 = 4.0; +pub const SPARK_LIFE: f64 = 0.45; +/// How hard the edge of the map pushes you back, per unit you're past it. +const EDGE_PUSH: f64 = 3.0; + +#[derive(Clone, Copy, Debug, Default)] +pub struct Controls { + /// 0 (engines off) to 1 (full). + pub thrust: f64, + /// -1 (anticlockwise) to 1 (clockwise), as a fraction of `TURN_RATE`. + pub turn: f64, + /// Turn to face against your direction of travel, ready to brake. + pub retro: bool, + pub firing: bool, +} + +pub struct Ship { + pub pos: Vec2, + pub vel: Vec2, + /// 0 is up the screen; see `Vec2::from_heading`. + pub heading: f64, + pub thrusting: bool, +} + +/// Ore floating free after a rock breaks, waiting to be scooped up. +pub struct Flotsam { + pub pos: Vec2, + pub vel: Vec2, + pub ore: &'static Ore, + pub age: f64, +} + +/// A spark of engine exhaust. +pub struct Spark { + pub pos: Vec2, + pub vel: Vec2, + pub age: f64, +} + +/// The mining laser, as fired this step. +pub struct Beam { + pub from: Vec2, + pub to: Vec2, + /// Whether it ended on something rather than at full range. + pub hit: bool, +} + +#[derive(Debug, PartialEq)] +pub enum Event { + Shattered { + size: Size, + ore: Option<&'static str>, + }, + Collected(&'static Ore), + /// Flew over ore with no room for it. + HoldFull, +} + +pub struct Flight { + pub locale: Locale, + /// Seconds since you arrived. + pub time: f64, + pub ship: Ship, + pub flotsam: Vec, + pub sparks: Vec, + pub beam: Option, + /// How fast the laser cuts: 1 as fitted, more with better lenses. + pub laser_power: f64, + rng: Rng, +} + +impl Flight { + /// Arrives at `locale`, facing whatever's in the middle of it. + pub fn new(locale: Locale, seed: u64) -> Flight { + let pos = locale.arrival; + let heading = if pos.length() > 0.0 { (-pos).heading() } else { 0.0 }; + Flight { + locale, + time: 0.0, + ship: Ship { pos, vel: Vec2::ZERO, heading, thrusting: false }, + flotsam: Vec::new(), + sparks: Vec::new(), + beam: None, + laser_power: 1.0, + rng: seed::rng(seed::derive(seed, "flight", 0)), + } + } + + pub fn speed(&self) -> f64 { + self.ship.vel.length() + } + + /// Whether you're over the planet or moon in the middle of the map. + pub fn over_body(&self) -> bool { + self.locale.body.as_ref().is_some_and(|b| self.ship.pos.length() <= b.radius) + } + + /// Whether you're close enough to the station to dock. + pub fn at_dock(&self) -> bool { + self.locale.dock.is_some() && self.ship.pos.length() <= DOCK_RADIUS + } + + /// The wreck close enough to board, if any, as an index into + /// `locale.wrecks`. + pub fn wreck_alongside(&self) -> Option { + self.locale.wrecks.iter().position(|w| w.pos.distance(self.ship.pos) <= BOARDING_RANGE) + } + + /// Whether you're beyond the edge of the map, being pushed back. + pub fn past_edge(&self) -> bool { + let e = self.locale.extent; + self.ship.pos.x.abs() > e || self.ship.pos.y.abs() > e + } + + /// Whether you're pointing (near enough) against your direction of travel. + pub fn facing_retro(&self) -> bool { + let backwards = (-self.ship.vel).heading(); + self.speed() < 0.5 || turn_between(self.ship.heading, backwards).abs() < 0.05 + } + + /// Advances the world by `dt` seconds. `room` is how many more tons of + /// ore the hold can take. + pub fn step(&mut self, dt: f64, controls: Controls, room: u32) -> Vec { + let mut events = Vec::new(); + self.time += dt; + let extent = self.locale.extent; + for rock in &mut self.locale.rocks { + rock.step(dt, extent); + } + self.steer(dt, controls); + self.fire(dt, controls.firing, &mut events); + + for spark in &mut self.sparks { + spark.pos += spark.vel * dt; + spark.age += dt; + } + self.sparks.retain(|s| s.age < SPARK_LIFE); + + let mut collected = 0; + for f in &mut self.flotsam { + f.pos += f.vel * dt; + f.vel = f.vel * (1.0 - 0.3 * dt); + f.age += dt; + if f.pos.distance(self.ship.pos) <= PICKUP_RANGE { + if collected < room { + collected += 1; + f.age = f64::INFINITY; + events.push(Event::Collected(f.ore)); + } else if !events.contains(&Event::HoldFull) { + events.push(Event::HoldFull); + } + } + } + self.flotsam.retain(|f| f.age < FLOTSAM_LIFE); + events + } + + fn steer(&mut self, dt: f64, controls: Controls) { + let ship = &mut self.ship; + ship.heading += controls.turn.clamp(-1.0, 1.0) * TURN_RATE * dt; + if controls.retro && ship.vel.length() > 0.5 { + let off = turn_between(ship.heading, (-ship.vel).heading()); + let most = TURN_RATE * dt; + ship.heading += off.clamp(-most, most); + } + ship.heading = ship.heading.rem_euclid(TAU); + + let ahead = Vec2::from_heading(ship.heading); + let thrust = controls.thrust.clamp(0.0, 1.0); + ship.thrusting = thrust > 0.0; + if ship.thrusting { + ship.vel += ahead * (THRUST * thrust * dt); + // Exhaust: a spark out the back, thrown off a little to one side. + let side = Vec2::new(-ahead.y, ahead.x) * self.rng.random_range(-2.5..2.5); + self.sparks.push(Spark { + pos: ship.pos - ahead, + vel: ship.vel - ahead * self.rng.random_range(10.0..16.0) + side, + age: 0.0, + }); + } + // The edge of the map: a spring that pushes you back in. + let e = self.locale.extent; + let over = |v: f64| (v.abs() - e).max(0.0) * v.signum(); + ship.vel -= Vec2::new(over(ship.pos.x), over(ship.pos.y)) * (EDGE_PUSH * dt); + + ship.vel = ship.vel * (1.0 - DRAG * dt); + if ship.vel.length() > MAX_SPEED { + ship.vel = ship.vel.normalised() * MAX_SPEED; + } + ship.pos += ship.vel * dt; + } + + /// Casts the laser along your heading, wearing down the first rock it + /// meets. The planet or station in the middle stops it too, unharmed. + fn fire(&mut self, dt: f64, firing: bool, events: &mut Vec) { + self.beam = None; + if !firing { + return; + } + let ahead = Vec2::from_heading(self.ship.heading); + let from = self.ship.pos + ahead; + let mut nearest: Option<(f64, Option)> = None; + let mut consider = |centre: Vec2, r: f64, what: Option| { + if let Some(d) = ray_hits_circle(from, ahead, centre, r) + && d <= LASER_RANGE + && nearest.is_none_or(|(n, _)| d < n) + { + nearest = Some((d, what)); + } + }; + for (i, rock) in self.locale.rocks.iter().enumerate() { + consider(rock.pos, rock.size.radius(), Some(i)); + } + if let Some(r) = self.locale.solid_radius() { + consider(Vec2::ZERO, r, None); + } + let reach = nearest.map_or(LASER_RANGE, |(d, _)| d); + self.beam = Some(Beam { from, to: from + ahead * reach, hit: nearest.is_some() }); + if let Some((_, Some(i))) = nearest { + self.locale.rocks[i].integrity -= dt * self.laser_power; + if self.locale.rocks[i].integrity <= 0.0 { + self.shatter(i, events); + } + } + } + + /// Breaks a rock into two smaller ones drifting apart, and spills its + /// ore, if it had any. + fn shatter(&mut self, i: usize, events: &mut Vec) { + let rock = self.locale.rocks.swap_remove(i); + let rng = &mut self.rng; + let vel = rock.velocity(); + if let Some(size) = rock.size.smaller() { + let apart = Vec2::from_heading(rng.random_range(0.0..TAU)); + for side in [-1.0, 1.0] { + let pos = rock.pos + apart * (side * rock.size.radius() * 0.6); + let child = if rock.circling == 0.0 { + let kick = apart * (side * rng.random_range(0.5..1.5)); + Rock::new(pos, rock.vel + kick, 0.0, size, rock.ore) + } else { + // Keep circling at about the parent's distance. + let circling = rock.circling * rng.random_range(0.9..1.1); + Rock::new(pos, Vec2::ZERO, circling, size, rock.ore) + }; + self.locale.rocks.push(child); + } + } + if let Some(ore) = rock.ore { + let chunks = match rock.size { + Size::Small => 1, + Size::Medium => 2, + Size::Large => 3, + }; + for _ in 0..chunks { + let drift = + Vec2::from_heading(rng.random_range(0.0..TAU)) * rng.random_range(1.0..4.0); + self.flotsam.push(Flotsam { pos: rock.pos, vel: vel + drift, ore, age: 0.0 }); + } + } + events.push(Event::Shattered { size: rock.size, ore: rock.ore.map(|o| o.name) }); + } +} + +/// How far along a ray (from `from`, along unit vector `dir`) it first +/// touches a circle, if it does. Zero if it starts inside. +fn ray_hits_circle(from: Vec2, dir: Vec2, centre: Vec2, r: f64) -> Option { + let to_centre = centre - from; + let along = to_centre.dot(dir); + let miss_sq = to_centre.dot(to_centre) - along * along; + if along < -r || miss_sq > r * r { + return None; + } + Some((along - (r * r - miss_sq).sqrt()).max(0.0)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::space::locale::{self, ORES}; + use crate::space::system::{self, PlaceKind}; + + const DT: f64 = 1.0 / 30.0; + + /// Flying in the belt of system 1, with the rocks cleared away. + fn flight() -> Flight { + let s = system::generate(1); + let belt = s.places.iter().position(|p| p.kind == PlaceKind::Belt).unwrap(); + let mut locale = locale::generate(&s, belt, 0.0, None, &Default::default()); + locale.rocks.clear(); + Flight::new(locale, 1) + } + + fn run(f: &mut Flight, seconds: f64, controls: Controls) -> Vec { + let mut events = Vec::new(); + for _ in 0..(seconds / DT).round() as usize { + events.extend(f.step(DT, controls, 99)); + } + events + } + + /// A rock that sits still at `pos`. + fn rock_at(pos: Vec2, size: Size) -> Rock { + Rock::new(pos, Vec2::ZERO, 0.0, size, Some(&ORES[0])) + } + + #[test] + fn thrust_pushes_along_the_heading_up_to_a_limit() { + let mut f = flight(); + f.ship.heading = TAU / 4.0; + run(&mut f, 1.0, Controls { thrust: 1.0, ..Controls::default() }); + assert!(f.ship.vel.x > 15.0 && f.ship.vel.y.abs() < 1e-6, "heading right goes right"); + run(&mut f, 3.0, Controls { thrust: 1.0, ..Controls::default() }); + assert!((f.speed() - MAX_SPEED).abs() < 1e-6, "speed is capped"); + } + + #[test] + fn drag_slows_you_when_coasting() { + let mut f = flight(); + f.ship.vel = Vec2::new(20.0, 0.0); + run(&mut f, 3.0, Controls::default()); + assert!(f.speed() < 15.0 && f.speed() > 5.0, "coasting decays, slowly: {}", f.speed()); + } + + #[test] + fn retro_turns_you_to_face_backwards() { + let mut f = flight(); + f.ship.vel = Vec2::new(0.0, -10.0); + f.ship.heading = 0.0; + assert!(!f.facing_retro()); + run(&mut f, 1.5, Controls { retro: true, ..Controls::default() }); + assert!(f.facing_retro(), "heading {} after retro", f.ship.heading); + } + + #[test] + fn the_edge_pushes_you_back() { + let mut f = flight(); + f.ship.pos = Vec2::new(f.locale.extent - 5.0, 0.0); + f.ship.vel = Vec2::new(MAX_SPEED, 0.0); + run(&mut f, 5.0, Controls::default()); + assert!(f.ship.vel.x < 0.0, "turned round"); + assert!(f.ship.pos.x < f.locale.extent + 30.0); + } + + #[test] + fn mining_breaks_rocks_and_ore_can_be_scooped_up() { + let mut f = flight(); + f.ship.pos = Vec2::new(50.0, 0.0); + f.ship.heading = 0.0; + f.locale.rocks = vec![rock_at(Vec2::new(50.0, -10.0), Size::Medium)]; + // Long enough to break the rock, not long enough to break its pieces. + let events = run(&mut f, 1.1, Controls { firing: true, ..Controls::default() }); + assert!(events.contains(&Event::Shattered { size: Size::Medium, ore: Some("iron ore") })); + assert_eq!(f.locale.rocks.len(), 2, "a medium rock breaks in two"); + assert!(f.locale.rocks.iter().all(|r| r.size == Size::Small)); + assert_eq!(f.flotsam.len(), 2, "a medium rock spills two chunks"); + + f.ship.pos = f.flotsam[0].pos; + let events = f.step(DT, Controls::default(), 1); + assert!(events.iter().any(|e| matches!(e, Event::Collected(_)))); + } + + #[test] + fn a_full_hold_leaves_ore_floating() { + let mut f = flight(); + f.flotsam = vec![Flotsam { pos: f.ship.pos, vel: Vec2::ZERO, ore: &ORES[0], age: 0.0 }]; + let events = f.step(DT, Controls::default(), 0); + assert_eq!(events, vec![Event::HoldFull]); + assert_eq!(f.flotsam.len(), 1); + } + + #[test] + fn the_laser_stops_at_the_first_rock() { + let mut f = flight(); + f.ship.pos = Vec2::new(50.0, 0.0); + f.ship.heading = 0.0; + f.locale.rocks = vec![ + rock_at(Vec2::new(50.0, -15.0), Size::Small), + rock_at(Vec2::new(50.0, -6.0), Size::Small), + ]; + f.step(DT, Controls { firing: true, ..Controls::default() }, 0); + let beam = f.beam.as_ref().unwrap(); + assert!(beam.hit && beam.to.y > -6.0 && beam.to.y < -5.0, "beam ends at {:?}", beam.to); + assert!(f.locale.rocks[1].integrity < f.locale.rocks[0].integrity); + } + + #[test] + fn planets_stop_the_laser_and_can_be_landed_on() { + let s = system::generate(1); + let mut f = + Flight::new(locale::generate(&s, s.home_place(), 0.0, None, &Default::default()), 1); + f.locale.rocks.clear(); + let radius = f.locale.body.as_ref().unwrap().radius; + assert!(!f.over_body()); + f.step(DT, Controls { firing: true, ..Controls::default() }, 0); + let beam = f.beam.as_ref().unwrap(); + assert!(beam.hit, "you arrive facing the planet, in range"); + assert!((beam.to.length() - radius).abs() < 1e-6); + f.ship.pos = Vec2::new(0.0, radius - 1.0); + assert!(f.over_body()); + } +} -- cgit v1.2.3