diff options
Diffstat (limited to 'src/scenes/system_map.rs')
| -rw-r--r-- | src/scenes/system_map.rs | 830 |
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); + } +} |