summaryrefslogtreecommitdiff
path: root/src/space/locale.rs
blob: d6339b90577b999ca307d5546e4c14e2da204822 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
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<Size> {
        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<Body>,
    pub dock: Option<Dock>,
    pub backdrops: Vec<Backdrop>,
    pub rocks: Vec<Rock>,
    pub wrecks: Vec<LocalWreck>,
    /// 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<f64> {
        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<f64>,
    stripped: &HashSet<u64>,
) -> 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<Rock>, 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);
    }
}