//! 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()); } }