summaryrefslogtreecommitdiff
path: root/src/space/flight.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/space/flight.rs')
-rw-r--r--src/space/flight.rs434
1 files changed, 434 insertions, 0 deletions
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<Flotsam>,
+ pub sparks: Vec<Spark>,
+ pub beam: Option<Beam>,
+ /// 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<usize> {
+ 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<Event> {
+ 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<Event>) {
+ 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<usize>)> = None;
+ let mut consider = |centre: Vec2, r: f64, what: Option<usize>| {
+ 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<Event>) {
+ 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<f64> {
+ 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<Event> {
+ 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());
+ }
+}