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