summaryrefslogtreecommitdiff
path: root/src/scenes/system_map.rs
diff options
context:
space:
mode:
authorskylar <me@casper>2026-11-11 15:24:37 -0800
committerskylar <me@casper>2026-11-11 15:24:37 -0800
commit9ccf951473e21a8b90c25161156fa09a9b227a7b (patch)
treee7bd9858a4cdbc5ae50069db8ca0cf868e436dec /src/scenes/system_map.rs
parentd36c54d996f3c14ae811312dbbe621e12a39edde (diff)
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 <noreply@anthropic.com>
Diffstat (limited to 'src/scenes/system_map.rs')
-rw-r--r--src/scenes/system_map.rs830
1 files changed, 830 insertions, 0 deletions
diff --git a/src/scenes/system_map.rs b/src/scenes/system_map.rs
new file mode 100644
index 0000000..65c6011
--- /dev/null
+++ b/src/scenes/system_map.rs
@@ -0,0 +1,830 @@
+//! The system map: the star, every orbit, where everything is today, and a
+//! list of places to travel to with how long each trip takes.
+//!
+//! Distances from the star are drawn on a square-root scale, so the inner
+//! planets aren't crushed into the middle and the outer ones still fit.
+//! Moons are too close to their planets to see at any scale that shows the
+//! whole system, so they're drawn just off their planets, in the right
+//! direction.
+//!
+//! Choosing a place shows where it will be when you get there: trips aim
+//! for that, not for where it is now, and a survey of what it's like.
+//!
+//! Every place is marked with what you'll find if you land or dock there:
+//! a capital city (⌂), a station that only trades (¤), or neither (·).
+//!
+//! `g` swaps this for the star map, to jump to another system.
+
+use std::f64::consts::TAU;
+
+use ratatui::Frame;
+use ratatui::crossterm::event::{KeyCode, KeyEvent};
+use ratatui::layout::{Constraint, Layout, Rect};
+use ratatui::style::{Color, Modifier, Style, Stylize};
+use ratatui::symbols::Marker;
+use ratatui::text::{Line, Span};
+use ratatui::widgets::canvas::{Canvas, Context, Points};
+use ratatui::widgets::{Block, Paragraph};
+
+use super::galaxy_map::GalaxyMap;
+use super::keys;
+use super::sky;
+use super::{Scene, Transition};
+use crate::app::Ctx;
+use crate::names::Culture;
+use crate::space::Vec2;
+use crate::space::orbit;
+use crate::space::survey::{self, FREEZING, Survey, Temperature};
+use crate::space::system::{Landing, PlaceKind, Rgb, System, Wreck};
+use crate::town;
+
+const PANEL_WIDTH: u16 = 46;
+/// Rows for what's known about the chosen place.
+const ABOUT_HEIGHT: u16 = 20;
+/// The labels down the left of the survey: "temp", "air".
+const LABEL_WIDTH: usize = 7;
+const ACCENT: Color = Color::Rgb(240, 200, 110);
+const DIM: Color = Color::Rgb(110, 114, 132);
+const ORBIT: Color = Color::Rgb(58, 62, 80);
+const CAPITAL: Color = Color::Rgb(130, 214, 140);
+const MARKET: Color = Color::Rgb(110, 196, 226);
+const WRECK: Color = Color::Rgb(255, 90, 80);
+/// A beacon that's failing, or will have failed by the time you get there.
+pub(crate) const FAINT_WRECK: Color = Color::Rgb(150, 76, 70);
+/// A beacon with this few days left is failing.
+pub(crate) const FAILING: f64 = 5.0;
+const SUIT_UP: Color = Color::Rgb(240, 150, 90);
+
+pub struct SystemMap {
+ selected: usize,
+}
+
+impl SystemMap {
+ pub fn new(ctx: &Ctx) -> SystemMap {
+ SystemMap { selected: ctx.location }
+ }
+
+ /// Days to get from where you are to `place`.
+ fn trip(ctx: &Ctx, place: usize) -> f64 {
+ let s = &ctx.system;
+ let from = s.position(ctx.location, ctx.day);
+ orbit::plan_trip(from, ctx.day, |d| s.position(place, d))
+ }
+}
+
+impl Scene for SystemMap {
+ fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition {
+ let n = ctx.system.places.len();
+ match key.code {
+ KeyCode::Up | KeyCode::Char('k' | 'w') => self.selected = (self.selected + n - 1) % n,
+ KeyCode::Down | KeyCode::Char('j' | 's') => self.selected = (self.selected + 1) % n,
+ KeyCode::Enter | KeyCode::Char('t') => {
+ if self.selected != ctx.location {
+ let days = Self::trip(ctx, self.selected);
+ ctx.travel(self.selected, days);
+ }
+ return Transition::Pop;
+ }
+ KeyCode::Char('.') => ctx.day += 1.0,
+ KeyCode::Char('g') => return Transition::Replace(vec![Box::new(GalaxyMap::new(ctx))]),
+ KeyCode::Esc | KeyCode::Char('m' | 'q') => return Transition::Pop,
+ _ => {}
+ }
+ Transition::None
+ }
+
+ fn render(&self, frame: &mut Frame, ctx: &Ctx) {
+ let area = frame.area();
+ let (map, panel) = if area.width >= 100 {
+ let [map, panel] =
+ Layout::horizontal([Constraint::Fill(1), Constraint::Length(PANEL_WIDTH)])
+ .areas(area);
+ (Some(map), panel)
+ } else {
+ (None, area)
+ };
+ if let Some(map) = map {
+ self.draw_orrery(frame, map, ctx);
+ }
+ self.draw_panel(frame, panel, ctx);
+ }
+}
+
+/// Positions in AU to canvas points, on a square-root scale out to `extent`.
+struct Scale {
+ radius: f64,
+ extent: f64,
+}
+
+impl Scale {
+ fn point(&self, p: Vec2) -> (f64, f64) {
+ let d = p.length();
+ if d == 0.0 {
+ return (0.0, 0.0);
+ }
+ let r = self.radius * (d / self.extent).sqrt();
+ (p.x / d * r, p.y / d * r)
+ }
+}
+
+impl SystemMap {
+ fn draw_orrery(&self, frame: &mut Frame, area: Rect, ctx: &Ctx) {
+ let s = &ctx.system;
+ let day = ctx.day;
+ // One unit per braille dot: two across a cell, four down, and dots
+ // are about square.
+ let half_w = f64::from(area.width);
+ let half_h = 2.0 * f64::from(area.height);
+ let scale = Scale { radius: half_w.min(half_h) * 0.92, extent: s.extent() };
+
+ let trip = (self.selected != ctx.location).then(|| Self::trip(ctx, self.selected));
+ let canvas = Canvas::default()
+ .marker(Marker::Braille)
+ .background_color(Color::Reset)
+ .x_bounds([-half_w, half_w])
+ .y_bounds([-half_h, half_h])
+ .paint(|c| {
+ draw_orbits(c, s, &scale);
+ c.layer();
+ if let Some(days) = trip {
+ let from = scale.point(s.position(ctx.location, day));
+ let to = scale.point(s.position(self.selected, day + days));
+ let steps = ((to.0 - from.0).hypot(to.1 - from.1) / 3.0) as usize;
+ let dots: Vec<(f64, f64)> = (0..=steps)
+ .map(|i| {
+ let t = i as f64 / steps.max(1) as f64;
+ (from.0 + (to.0 - from.0) * t, from.1 + (to.1 - from.1) * t)
+ })
+ .collect();
+ c.draw(&Points { coords: &dots, color: ACCENT });
+ c.print(to.0, to.1, Span::raw("◌").fg(ACCENT));
+ }
+ self.draw_bodies(c, ctx, &scale);
+ });
+ frame.render_widget(canvas, area);
+ }
+
+ fn draw_bodies(&self, c: &mut Context, ctx: &Ctx, scale: &Scale) {
+ let s = &ctx.system;
+ let day = ctx.day;
+ c.print(0.0, 0.0, Span::raw("☼").fg(sky::rgb(s.star.rgb)).bold());
+ let mut labels = Vec::new();
+ for (i, place) in s.places.iter().enumerate() {
+ let here = i == ctx.location;
+ let chosen = i == self.selected;
+ let (x, y) = match place.kind {
+ PlaceKind::Planet(p) => {
+ let (x, y) = scale.point(s.planets[p].orbit.position(day));
+ let glyph = if here { "@" } else { "●" };
+ let colour = if here { Color::White } else { sky::rgb(s.planets[p].kind.rgb) };
+ c.print(x, y, Span::raw(glyph).fg(colour).bold());
+ if let Landing::Capital(_) = s.landing(i) {
+ c.print(x + 2.0, y, Span::raw("⌂").fg(CAPITAL));
+ }
+ (x, y)
+ }
+ PlaceKind::Moon(p, m) => {
+ let (px, py) = scale.point(s.planets[p].orbit.position(day));
+ let moon = &s.planets[p].moons[m];
+ let dir = moon.orbit.position(day).normalised();
+ let r = 4.0 + 2.5 * m as f64;
+ let (x, y) = (px + dir.x * r, py + dir.y * r);
+ let colour =
+ if here || chosen { Color::White } else { sky::rgb(moon.kind.rgb) };
+ if here {
+ c.print(x, y, Span::raw("@").fg(Color::White).bold());
+ } else if let Landing::Capital(_) = s.landing(i) {
+ c.print(
+ x,
+ y,
+ Span::raw("⌂").fg(if chosen { Color::White } else { CAPITAL }),
+ );
+ } else {
+ c.draw(&Points { coords: &[(x, y)], color: colour });
+ }
+ (x, y)
+ }
+ PlaceKind::Belt => {
+ let (x, y) = scale.point(s.belt.region.position(day));
+ if here {
+ c.print(x, y, Span::raw("@").fg(Color::White).bold());
+ } else if chosen {
+ c.print(x, y, Span::raw("+").fg(ACCENT));
+ }
+ (x, y)
+ }
+ PlaceKind::Station(k) => {
+ let (x, y) = scale.point(s.position(i, day));
+ let (_, colour) = sky::station_art(s.stations[k].culture);
+ let glyph = if here { "@" } else { "#" };
+ c.print(
+ x,
+ y,
+ Span::raw(glyph).fg(if here { Color::White } else { sky::rgb(colour) }),
+ );
+ (x, y)
+ }
+ };
+ if chosen || here {
+ let style =
+ if chosen { Style::new().fg(ACCENT) } else { Style::new().fg(Color::White) };
+ labels.push((x + 4.0, y, Span::styled(format!(" {}", place.name), style)));
+ }
+ }
+ // Names last, so nothing nearby is drawn over them.
+ for (x, y, label) in labels {
+ c.print(x, y, label);
+ }
+ }
+
+ fn draw_panel(&self, frame: &mut Frame, area: Rect, ctx: &Ctx) {
+ let s = &ctx.system;
+ let block = Block::bordered()
+ .title(format!(" the {} system · day {:.1} ", s.name, ctx.day))
+ .title_bottom(
+ Line::from(format!(
+ " {} of {} · fuel {}/{} ",
+ capitalise(s.kind.name()),
+ s.culture.name(),
+ ctx.hold.fuel,
+ ctx.hold.fuel_capacity
+ ))
+ .fg(DIM),
+ )
+ .title_style(Style::new().fg(ACCENT).bold())
+ .border_style(Style::new().fg(DIM));
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+
+ let hints = keys::packed(
+ &[
+ ("↑↓", "choose"),
+ ("enter", "travel"),
+ (".", "wait a day"),
+ ("g", "star map"),
+ ("esc", "back"),
+ ],
+ inner.width,
+ );
+ let [list, about, keys] = Layout::vertical([
+ Constraint::Min(3),
+ Constraint::Length(ABOUT_HEIGHT),
+ Constraint::Length(1 + hints.len() as u16),
+ ])
+ .areas(inner);
+
+ // Scroll to keep the choice in view.
+ let rows = usize::from(list.height);
+ let first = self
+ .selected
+ .saturating_sub(rows.saturating_sub(1) / 2)
+ .min(s.places.len().saturating_sub(rows));
+ let wrecks = ctx.wrecks();
+ let lines: Vec<Line> = s
+ .places
+ .iter()
+ .enumerate()
+ .skip(first)
+ .take(rows)
+ .map(|(i, place)| {
+ let indent = match place.kind {
+ PlaceKind::Moon(..) | PlaceKind::Station(_) => " ",
+ _ => "",
+ };
+ let (mark, colour) = mark(s.landing(i));
+ let days = if i == ctx.location { 0.0 } else { Self::trip(ctx, i) };
+ let trip = if i == ctx.location {
+ "you're here".to_string()
+ } else {
+ format!("{days:.1} d")
+ };
+ // Bright if there's a wreck with time to spare; faint if all
+ // that's there are beacons on their last legs, which wait
+ // only if you go straight there.
+ let here = wrecks.iter().filter(|w| w.place == i);
+ let worth = here.clone().any(|w| w.leaves - ctx.day > FAILING);
+ let beacon = match (here.count(), worth) {
+ (0, _) => Span::raw(" "),
+ (_, true) => Span::raw("!").fg(WRECK),
+ (_, false) => Span::raw("!").fg(FAINT_WRECK),
+ };
+ let name_width = usize::from(list.width).saturating_sub(16 + indent.len());
+ let name: String = place.name.chars().take(name_width).collect();
+ let mut spans = vec![
+ Span::raw(format!(" {mark} ")).fg(colour),
+ Span::raw(format!("{indent}{name:<name_width$}")),
+ beacon,
+ Span::raw(format!("{trip:>12}")).fg(DIM),
+ ];
+ if i == self.selected {
+ for span in &mut spans {
+ span.style = span.style.add_modifier(Modifier::REVERSED);
+ }
+ }
+ Line::from(spans)
+ })
+ .collect();
+ frame.render_widget(Paragraph::new(lines), list);
+
+ frame.render_widget(
+ // Wrapped already, so that wrapped lines stay indented.
+ Paragraph::new(self.about(ctx, usize::from(about.width))),
+ about,
+ );
+ let legend = Line::from(vec![
+ Span::raw("⌂").fg(CAPITAL),
+ Span::raw(" capital ").fg(DIM),
+ Span::raw("¤").fg(MARKET),
+ Span::raw(" trade only ").fg(DIM),
+ Span::raw("·").fg(DIM),
+ Span::raw(" neither ").fg(DIM),
+ Span::raw("!").fg(WRECK),
+ Span::raw(" wreck").fg(DIM),
+ ]);
+ let mut lines = vec![legend];
+ lines.extend(hints);
+ frame.render_widget(Paragraph::new(lines), keys);
+ }
+
+ /// What's known about the chosen place: what it is, what you'll find if
+ /// you land, the trip there, and a survey of what it's like.
+ fn about(&self, ctx: &Ctx, width: usize) -> Vec<Line<'static>> {
+ let s = &ctx.system;
+ let i = self.selected;
+ let place = &s.places[i];
+ let who = |people: Option<Culture>, home: bool| match people {
+ Some(c) if home && s.kind != crate::space::system::SystemKind::Seat => {
+ format!("chief world of {}", c.name())
+ }
+ Some(c) if home => format!("homeworld of {}", c.name()),
+ Some(c) => format!("settled by {}", c.name()),
+ None => "uninhabited".to_string(),
+ };
+ let (what, orbit_line) = match place.kind {
+ PlaceKind::Planet(p) => {
+ let planet = &s.planets[p];
+ (
+ format!("{} · {}", planet.kind.name, who(planet.people, p == s.home)),
+ format!(
+ "{:.2} AU from the star; a year of {:.0} days",
+ planet.orbit.a, planet.orbit.period
+ ),
+ )
+ }
+ PlaceKind::Moon(p, m) => {
+ let moon = &s.planets[p].moons[m];
+ (
+ format!("{} · {}", moon.kind.name, who(moon.people, false)),
+ format!(
+ "a moon of {}, going round every {:.1} days",
+ s.planets[p].name, moon.orbit.period
+ ),
+ )
+ }
+ PlaceKind::Belt => (
+ "an asteroid belt: good mining".to_string(),
+ format!("from {:.2} to {:.2} AU from the star", s.belt.inner, s.belt.outer),
+ ),
+ PlaceKind::Station(k) => {
+ let st = &s.stations[k];
+ let point = if st.leading { "L4" } else { "L5" };
+ (
+ format!("a {} station", st.culture.adjective()),
+ format!(
+ "at {}'s {point} point, sixty degrees {} it",
+ s.planets[st.planet].name,
+ if st.leading { "ahead of" } else { "behind" }
+ ),
+ )
+ }
+ };
+ let prose = |text: String, style: Style| wrap(Vec::new(), &[(text, style)], width);
+ let mut lines = vec![Line::from(place.name.clone()).fg(ACCENT).bold()];
+ lines.extend(prose(what, Style::new()));
+ lines.extend(landing_line(s, i, width));
+ if i != ctx.location {
+ let days = Self::trip(ctx, i);
+ let now = s.position(ctx.location, ctx.day);
+ let there = s.position(i, ctx.day + days);
+ lines.extend(prose(
+ format!(
+ "{:.2} AU away by the time you arrive: {:.1} days, on day {:.1}",
+ now.distance(there),
+ days,
+ ctx.day + days
+ ),
+ Style::new(),
+ ));
+ }
+ let arrival = if i == ctx.location { ctx.day } else { ctx.day + Self::trip(ctx, i) };
+ for w in ctx.wrecks().iter().filter(|w| w.place == i) {
+ let found = if w.calling(ctx.day) {
+ format!(
+ "A distress beacon: {}, a {}. {}",
+ w.name,
+ w.description,
+ beacon_age(w, ctx.day)
+ )
+ } else {
+ format!(
+ "A wreck: {}, a {}. Its beacon failed on your way here, but you came by \
+ its last fix.",
+ w.name, w.description
+ )
+ };
+ let mut phrases = vec![(found, Style::new().fg(Color::Rgb(255, 120, 100)))];
+ if let Some(warning) = beacon_warning(w, ctx.day, arrival) {
+ phrases.push((format!(" {warning}"), Style::new().fg(FAINT_WRECK)));
+ }
+ lines.extend(wrap(Vec::new(), &phrases, width));
+ }
+ lines.push(Line::default());
+ let station = matches!(place.kind, PlaceKind::Station(_));
+ let giant = match place.kind {
+ PlaceKind::Planet(p) => s.planets[p].kind.giant,
+ _ => false,
+ };
+ lines.extend(facts(&survey::survey(s, i, arrival), station, giant, width));
+ lines.push(Line::default());
+ lines.extend(prose(orbit_line, Style::new().fg(DIM)));
+ lines
+ }
+}
+
+/// How long a wreck's beacon has been calling: "Calling for 12 days."
+pub(crate) fn beacon_age(w: &Wreck, day: f64) -> String {
+ match (day - w.arrived).floor() {
+ ..1.0 => "It started calling today.".into(),
+ 1.0 => "Calling since yesterday.".into(),
+ n => format!("Calling for {n:.0} days."),
+ }
+}
+
+/// Whether a beacon is failing, or will fail before you `arrive`. A wreck
+/// whose beacon has failed already is one you came by its last fix: there's
+/// nothing to warn of.
+pub(crate) fn beacon_warning(w: &Wreck, day: f64, arrive: f64) -> Option<String> {
+ let left = w.leaves - day;
+ if left <= 0.0 {
+ None
+ } else if w.leaves <= arrive {
+ Some(
+ "It'll fail on the way, but go straight there and you'll find it by its last fix."
+ .into(),
+ )
+ } else if left <= FAILING {
+ Some(match left.floor() {
+ ..1.0 => "Failing: hours left.".into(),
+ 1.0 => "Failing: a day left, maybe two.".into(),
+ n => format!("Failing: {n:.0} days left, maybe less."),
+ })
+ } else {
+ None
+ }
+}
+
+/// How a place is marked in the list: what you'll find if you land.
+fn mark(landing: Landing) -> (&'static str, Color) {
+ match landing {
+ Landing::Capital(_) => ("⌂", CAPITAL),
+ Landing::Market(_) => ("¤", MARKET),
+ Landing::Aloft(_) | Landing::Wilds | Landing::Nowhere => ("·", DIM),
+ }
+}
+
+/// What you'll find if you land or dock at a place, in a sentence.
+fn landing_line(s: &System, place: usize, width: usize) -> Vec<Line<'static>> {
+ let landing = s.landing(place);
+ let (mark, colour) = mark(landing);
+ let (tag, rest) = match landing {
+ Landing::Capital(c) => (
+ "Capital",
+ format!(
+ ": {}. Land and walk in to its broker, doctor, scholar, tavern and shipyard, \
+ which sells fuel.",
+ town::name(town::seed_for(s.seed, place), c)
+ ),
+ ),
+ Landing::Market(_) => {
+ ("Trade only", ": dock to sell ore and buy fuel. No city, no shops.".to_string())
+ }
+ Landing::Aloft(c) => (
+ "Neither",
+ format!(": {} live up in its clouds, but there's no ground to land on.", c.name()),
+ ),
+ Landing::Wilds => {
+ ("Neither", ": no one lives here to trade with. Land and look around.".to_string())
+ }
+ Landing::Nowhere if s.places[place].kind == PlaceKind::Belt => {
+ ("Neither", ": nowhere to land, only rocks to mine.".to_string())
+ }
+ Landing::Nowhere => {
+ ("Neither", ": no one lives here, and there's no ground to land on.".to_string())
+ }
+ };
+ let head = vec![Span::raw(format!("{mark} ")).fg(colour)];
+ let phrases = [(format!("{tag}{rest}"), Style::new())];
+ let mut lines = wrap(head, &phrases, width);
+ // Colour the tag, which is always the first word or two.
+ if let Some(first) = lines.first_mut() {
+ let words = tag.split(' ').count();
+ for span in first.spans.iter_mut().skip(1).step_by(2).take(words) {
+ span.style = Style::new().fg(colour).bold();
+ }
+ }
+ lines
+}
+
+/// The survey, as labelled rows.
+fn facts(survey: &Survey, station: bool, giant: bool, width: usize) -> Vec<Line<'static>> {
+ let plain = Style::new();
+ let dim = Style::new().fg(DIM);
+ let mut lines = Vec::new();
+ if let Some(t) = survey.temperature {
+ lines.push(thermometer(t));
+ let range = (t.hottest - t.coldest >= 2.0).then(|| {
+ format!("{} to {} °C", survey::celsius(t.coldest), survey::celsius(t.hottest))
+ });
+ let detail = match (giant, range) {
+ (true, Some(range)) => Some(format!("{range} at the cloud tops")),
+ (true, None) => Some("at the cloud tops".to_string()),
+ (false, Some(range)) => Some(format!("{range} over its year")),
+ (false, None) => None,
+ };
+ if let Some(detail) = detail {
+ lines.extend(fact("", &[(detail, dim)], width));
+ }
+ }
+ if let Some(day) = &survey.day {
+ let mut text = day.clone();
+ if let Some(seasons) = survey.seasons {
+ text = format!("{text}; {seasons}");
+ }
+ lines.extend(fact(if station { "spin" } else { "day" }, &[(text, plain)], width));
+ }
+ if let Some(g) = survey.gravity {
+ let amount = if g < 0.1 { format!("{g:.2} g") } else { format!("{g:.1} g") };
+ let feel = match g {
+ g if g < 0.3 => "featherlight",
+ g if g < 0.75 => "light",
+ g if g < 1.25 => "",
+ g if g < 1.8 => "heavy",
+ _ => "crushing",
+ };
+ let mut parts = vec![(amount, plain)];
+ if !feel.is_empty() {
+ parts.push((format!("· {feel}"), dim));
+ }
+ if station {
+ parts.push(("· from its spin".to_string(), dim));
+ }
+ lines.extend(fact("pull", &parts, width));
+ }
+ let suit_up = ("suit up".to_string(), Style::new().fg(SUIT_UP));
+ let air = match survey.air {
+ Some((what, true)) => {
+ vec![
+ (what.to_string(), plain),
+ ("·".into(), dim),
+ ("breathable".into(), Style::new().fg(CAPITAL)),
+ ]
+ }
+ Some((what, false)) => vec![(what.to_string(), plain), ("·".into(), dim), suit_up],
+ None => vec![("none".to_string(), plain), ("·".into(), dim), suit_up],
+ };
+ lines.extend(fact("air", &air, width));
+ lines.extend(fact("sky", &[(survey.sky.clone(), plain)], width));
+ if let Some(n) = survey.population {
+ let mut text = headcount(n);
+ if giant {
+ text.push_str(", in cloud cities");
+ }
+ lines.extend(fact(if station { "aboard" } else { "people" }, &[(text, plain)], width));
+ }
+ lines
+}
+
+/// A labelled row of the survey.
+fn fact(label: &str, phrases: &[(String, Style)], width: usize) -> Vec<Line<'static>> {
+ let head = vec![Span::raw(format!("{label:<LABEL_WIDTH$}")).fg(DIM)];
+ wrap(head, phrases, width)
+}
+
+/// Phrases joined by spaces, each in its own style, after `head` and
+/// word-wrapped to `width`, with the overflow indented to line up under the
+/// first line's text. Each word is its own span, after the head.
+pub(crate) fn wrap(
+ head: Vec<Span<'static>>,
+ phrases: &[(String, Style)],
+ width: usize,
+) -> Vec<Line<'static>> {
+ let indent: usize = head.iter().map(|s| s.content.chars().count()).sum();
+ let room = width.saturating_sub(indent).max(10);
+ let mut rows: Vec<Vec<Span<'static>>> = vec![Vec::new()];
+ let mut used = 0;
+ for (text, style) in phrases {
+ for word in text.split_whitespace() {
+ let w = word.chars().count();
+ if used > 0 && used + 1 + w > room {
+ rows.push(Vec::new());
+ used = 0;
+ }
+ let row = rows.last_mut().unwrap();
+ if used > 0 {
+ row.push(Span::raw(" "));
+ used += 1;
+ }
+ row.push(Span::styled(word.to_string(), *style));
+ used += w;
+ }
+ }
+ let mut head = Some(head);
+ rows.into_iter()
+ .map(|spans| {
+ let mut line = head.take().unwrap_or_else(|| vec![Span::raw(" ".repeat(indent))]);
+ line.extend(spans);
+ Line::from(line)
+ })
+ .collect()
+}
+
+fn capitalise(s: &str) -> String {
+ let mut chars = s.chars();
+ chars.next().map_or(String::new(), |c| c.to_uppercase().chain(chars).collect())
+}
+
+/// Cells in the thermometer.
+const THERMOMETER: usize = 14;
+/// The thermometer's ends, in kelvin. It's marked on a log scale, so the
+/// temperate middle isn't squeezed.
+const COLDEST: f64 = 50.0;
+const HOTTEST: f64 = 1500.0;
+
+/// A thermometer from deep cold to molten, filled up to the temperature
+/// now and lit more faintly over the rest of its year's swing; then the
+/// temperature and a word for it.
+fn thermometer(t: Temperature) -> Line<'static> {
+ let cell = |kelvin: f64| {
+ let k = (kelvin / COLDEST).ln() / (HOTTEST / COLDEST).ln();
+ (k.clamp(0.0, 1.0) * (THERMOMETER - 1) as f64).round() as usize
+ };
+ let (low, high, now) = (cell(t.coldest), cell(t.hottest), cell(t.now));
+ let mut spans = vec![Span::raw(format!("{:<LABEL_WIDTH$}", "temp")).fg(DIM)];
+ for i in 0..THERMOMETER {
+ let kelvin = COLDEST * (HOTTEST / COLDEST).powf(i as f64 / (THERMOMETER - 1) as f64);
+ let colour = heat(kelvin);
+ spans.push(if i == now {
+ Span::raw("●").fg(Color::White).bold()
+ } else if i < now {
+ Span::raw("━").fg(sky::rgb(colour))
+ } else if (low..=high).contains(&i) {
+ Span::raw("━").fg(sky::shade(colour, 0.6))
+ } else {
+ Span::raw("─").fg(sky::shade(colour, 0.35))
+ });
+ }
+ let colour = sky::rgb(heat(t.now));
+ spans.push(Span::raw(format!(" {} °C ", survey::celsius(t.now))).fg(colour).bold());
+ spans.push(Span::raw(t.word()).fg(colour));
+ Line::from(spans)
+}
+
+/// The colour of a temperature: blue through ice-white and green to red.
+fn heat(kelvin: f64) -> Rgb {
+ const STOPS: [(f64, Rgb); 7] = [
+ (50.0, (96, 112, 236)),
+ (150.0, (120, 178, 255)),
+ (240.0, (176, 226, 244)),
+ (FREEZING + 15.0, (128, 218, 138)),
+ (320.0, (240, 210, 96)),
+ (450.0, (246, 142, 64)),
+ (900.0, (236, 72, 52)),
+ ];
+ let k = kelvin.clamp(STOPS[0].0, STOPS[STOPS.len() - 1].0);
+ let i = STOPS.iter().rposition(|&(at, _)| at <= k).unwrap().min(STOPS.len() - 2);
+ let ((a, ca), (b, cb)) = (STOPS[i], STOPS[i + 1]);
+ let f = ((k / a).ln() / (b / a).ln()).clamp(0.0, 1.0);
+ let mix = |x: u8, y: u8| (f64::from(x) + (f64::from(y) - f64::from(x)) * f).round() as u8;
+ (mix(ca.0, cb.0), mix(ca.1, cb.1), mix(ca.2, cb.2))
+}
+
+/// "2.1 billion", "48 million", "12,000".
+fn headcount(n: u64) -> String {
+ let short = |x: f64, unit: &str| {
+ let text = if x < 10.0 { format!("{x:.1}") } else { format!("{x:.0}") };
+ format!("{} {unit}", text.trim_end_matches(".0"))
+ };
+ match n {
+ n if n >= 1_000_000_000 => short(n as f64 / 1e9, "billion"),
+ n if n >= 1_000_000 => short(n as f64 / 1e6, "million"),
+ n => {
+ let digits = n.to_string();
+ let mut out = String::new();
+ for (i, ch) in digits.chars().enumerate() {
+ if i > 0 && (digits.len() - i) % 3 == 0 {
+ out.push(',');
+ }
+ out.push(ch);
+ }
+ out
+ }
+ }
+}
+
+fn draw_orbits(c: &mut Context, s: &System, scale: &Scale) {
+ for planet in &s.planets {
+ let points: Vec<(f64, f64)> = planet.orbit.outline(240).map(|p| scale.point(p)).collect();
+ c.draw(&Points { coords: &points, color: ORBIT });
+ }
+ // The belt: a scatter of dots, the same every time.
+ let belt: Vec<(f64, f64)> = (0..700)
+ .map(|i| {
+ let angle = sky::hash01(s.seed, i, 1) * TAU;
+ let r = s.belt.inner + (s.belt.outer - s.belt.inner) * sky::hash01(s.seed, i, 2);
+ scale.point(Vec2::new(r * angle.cos(), r * angle.sin()))
+ })
+ .collect();
+ c.draw(&Points { coords: &belt, color: Color::Rgb(104, 96, 86) });
+}
+
+#[cfg(test)]
+mod tests {
+ use ratatui::crossterm::event::KeyModifiers;
+
+ use super::*;
+ use crate::scenes::tests::test_ctx;
+
+ fn press(map: &mut SystemMap, ctx: &mut Ctx, code: KeyCode) -> Transition {
+ map.key(KeyEvent::new(code, KeyModifiers::NONE), ctx)
+ }
+
+ #[test]
+ fn travelling_takes_days_and_moves_you() {
+ let mut ctx = test_ctx();
+ let mut map = SystemMap::new(&ctx);
+ let start = ctx.location;
+ press(&mut map, &mut ctx, KeyCode::Down);
+ let destination = map.selected;
+ let days = SystemMap::trip(&ctx, destination);
+ assert!(matches!(press(&mut map, &mut ctx, KeyCode::Enter), Transition::Pop));
+ assert_eq!(ctx.location, destination);
+ assert_ne!(ctx.location, start);
+ assert!((ctx.day - days).abs() < 1e-9 && days > 0.0);
+ assert_eq!(ctx.departed, Some(0.0));
+ }
+
+ #[test]
+ fn a_beacon_that_fails_on_the_way_still_leads_you_there() {
+ let ctx = test_ctx();
+ let w = ctx.system.wrecks_on(0.0)[0].clone();
+ let warning = beacon_warning(&w, 0.0, w.leaves + 1.0).unwrap();
+ assert!(warning.contains("last fix"), "{warning}");
+ assert_eq!(beacon_warning(&w, 0.0, 0.5), None, "plenty of time");
+ let failed = w.leaves + 1.0;
+ assert_eq!(beacon_warning(&w, failed, failed), None, "you're there already");
+ }
+
+ #[test]
+ fn choosing_where_you_are_goes_nowhere() {
+ let mut ctx = test_ctx();
+ let mut map = SystemMap::new(&ctx);
+ press(&mut map, &mut ctx, KeyCode::Enter);
+ assert_eq!(ctx.day, 0.0);
+ press(&mut map, &mut ctx, KeyCode::Char('.'));
+ assert_eq!(ctx.day, 1.0, "but you can wait");
+ }
+
+ #[test]
+ fn numbers_read_naturally() {
+ assert_eq!(headcount(2_100_000_000), "2.1 billion");
+ assert_eq!(headcount(3_000_000_000), "3 billion");
+ assert_eq!(headcount(48_000_000), "48 million");
+ assert_eq!(headcount(12_000), "12,000");
+ assert_eq!(headcount(870), "870");
+ }
+
+ #[test]
+ fn the_map_says_where_the_capitals_are() {
+ let ctx = test_ctx();
+ let mut map = SystemMap::new(&ctx);
+ map.selected = ctx.system.home_place();
+ let text: String = map
+ .about(&ctx, 44)
+ .iter()
+ .flat_map(|l| l.spans.iter().map(|s| s.content.to_string()))
+ .collect();
+ assert!(text.contains("⌂ Capital: "), "{text}");
+ assert!(text.contains("°C"), "{text}");
+ let belt = ctx.system.places.iter().position(|p| p.kind == PlaceKind::Belt).unwrap();
+ assert_eq!(mark(ctx.system.landing(belt)).0, "·");
+ }
+
+ #[test]
+ fn the_list_wraps_round() {
+ let mut ctx = test_ctx();
+ let mut map = SystemMap::new(&ctx);
+ map.selected = 0;
+ press(&mut map, &mut ctx, KeyCode::Up);
+ assert_eq!(map.selected, ctx.system.places.len() - 1);
+ }
+}