summaryrefslogtreecommitdiff
path: root/src/scenes/galaxy_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/galaxy_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/galaxy_map.rs')
-rw-r--r--src/scenes/galaxy_map.rs929
1 files changed, 929 insertions, 0 deletions
diff --git a/src/scenes/galaxy_map.rs b/src/scenes/galaxy_map.rs
new file mode 100644
index 0000000..5059b01
--- /dev/null
+++ b/src/scenes/galaxy_map.rs
@@ -0,0 +1,929 @@
+//! The star map: every system in the galaxy, the lanes between them, and
+//! whose space is whose.
+//!
+//! The map is drawn at one scale, a light-year to a cell across (two down),
+//! and scrolls to keep the chosen system in view; an inset in the corner
+//! shows the whole galaxy and where the view is. Each people's territory is
+//! a faint stipple in their colour round the systems they hold, brighter
+//! along the borders.
+//!
+//! You know the way only to systems you've charted: those you've been to
+//! and those one lane from them. Choose one and the map plots the fewest
+//! jumps there through charted space; `enter` makes them, a jump's worth of
+//! fuel at a time. Beyond the charted systems are faint dots, unnamed,
+//! except for the four seats, which everyone knows.
+//!
+//! Run dry where no one sells fuel and `t` calls a tow, to the nearest
+//! system that does. It costs what it costs: what you can't pay, you owe.
+
+use std::collections::HashSet;
+
+use ratatui::Frame;
+use ratatui::buffer::Buffer;
+use ratatui::crossterm::event::{KeyCode, KeyEvent};
+use ratatui::layout::{Constraint, Layout, Rect};
+use ratatui::style::{Color, Style, Stylize};
+use ratatui::symbols::Marker;
+use ratatui::text::{Line, Span};
+use ratatui::widgets::canvas::{Canvas, Line as Lane, Points};
+use ratatui::widgets::{Block, Paragraph, Widget, Wrap};
+
+use super::keys;
+use super::port::FUEL;
+use super::sky;
+use super::system_map::{FAILING, FAINT_WRECK, SystemMap, wrap};
+use super::{Scene, Transition};
+use crate::app::Ctx;
+use crate::names::{self, Culture};
+use crate::space::Vec2;
+use crate::space::galaxy::Galaxy;
+use crate::space::system::{Landing, Rgb, System, SystemKind};
+use crate::trade;
+
+const PANEL_WIDTH: u16 = 44;
+/// Below this width the panel goes under the map instead of beside it.
+const SIDE_BY_SIDE: u16 = 90;
+const ACCENT: Color = Color::Rgb(240, 200, 110);
+const DIM: Color = Color::Rgb(110, 114, 132);
+const LANE: Color = Color::Rgb(86, 92, 116);
+const FAINT_LANE: Color = Color::Rgb(50, 53, 68);
+const NO_FUEL: Color = Color::Rgb(214, 112, 100);
+/// How far a people's territory reaches past their outermost stars, in
+/// light-years.
+const REACH: f64 = 11.0;
+/// The inset showing the whole galaxy, inside its border.
+const INSET: (u16, u16) = (26, 8);
+
+/// A people's colour on the map: the colour of their stations.
+pub fn people_rgb(culture: Culture) -> Rgb {
+ sky::station_art(culture).1
+}
+
+pub struct GalaxyMap {
+ selected: usize,
+ /// The chosen system, generated, if you've been there and it isn't
+ /// where you are (that's `ctx.system`).
+ known: Option<System>,
+ /// What happened when you last tried to go somewhere.
+ report: Option<String>,
+}
+
+impl GalaxyMap {
+ pub fn new(ctx: &Ctx) -> GalaxyMap {
+ GalaxyMap { selected: ctx.star, known: None, report: None }
+ }
+
+ fn select(&mut self, site: usize, ctx: &Ctx) {
+ if site == self.selected {
+ return;
+ }
+ self.selected = site;
+ self.report = None;
+ self.known =
+ (site != ctx.star && ctx.visited.contains(&site)).then(|| ctx.galaxy.system(site));
+ }
+
+ /// The system nearest the chosen one in a direction (y up), within a
+ /// cone either side of it.
+ fn step(&mut self, dir: Vec2, ctx: &Ctx) {
+ let g = &ctx.galaxy;
+ let from = g.sites[self.selected].pos;
+ let best = g
+ .sites
+ .iter()
+ .enumerate()
+ .filter(|&(i, _)| i != self.selected)
+ .filter_map(|(i, s)| {
+ let off = s.pos - from;
+ let d = off.length();
+ let cos = off.dot(dir) / d;
+ (cos > 0.5).then_some((i, d * (1.0 + 2.0 * (1.0 - cos))))
+ })
+ .min_by(|a, b| a.1.total_cmp(&b.1));
+ if let Some((i, _)) = best {
+ self.select(i, ctx);
+ }
+ }
+
+ /// The next system a lane from here leads to.
+ fn next_lane(&mut self, ctx: &Ctx) {
+ let lanes = &ctx.galaxy.sites[ctx.star].lanes;
+ let next = match lanes.iter().position(|&l| l == self.selected) {
+ Some(i) => lanes[(i + 1) % lanes.len()],
+ None => lanes[0],
+ };
+ self.select(next, ctx);
+ }
+
+ /// The jumps from here to the chosen system, through charted space.
+ fn route(&self, ctx: &Ctx, charted: &[bool]) -> Option<Vec<usize>> {
+ if !charted[self.selected] {
+ return None;
+ }
+ ctx.galaxy.route(ctx.star, self.selected, |i| charted[i])
+ }
+
+ /// Jumps to the chosen system, if you can.
+ fn travel(&mut self, ctx: &mut Ctx) -> Transition {
+ let g = &ctx.galaxy;
+ let to = self.selected;
+ let charted = g.charted(&ctx.visited);
+ let refusal = if to == ctx.star {
+ Some(format!("You're in {} already.", g.sites[to].name))
+ } else if !charted[to] {
+ Some(
+ "That system's uncharted: you don't know the lanes that far. Jump closer first."
+ .into(),
+ )
+ } else {
+ None
+ };
+ if let Some(refusal) = refusal {
+ self.report = Some(refusal);
+ return Transition::None;
+ }
+ let Some(route) = self.route(ctx, &charted) else {
+ self.report = Some("There's no charted route there.".into());
+ return Transition::None;
+ };
+ let jumps = route.len() as u32;
+ let fuel = ctx.hold.fuel;
+ if fuel < jumps {
+ self.report = Some(if fuel == 0 && ctx.stranded() {
+ "Your tank's dry, and no one here sells fuel. [t] calls a tow.".into()
+ } else if fuel == 0 {
+ "Your tank's dry. Fuel's sold here: at a station, or a city's shipyard.".into()
+ } else {
+ format!(
+ "That's {jumps} jumps, and you've fuel for {}. Choose somewhere nearer, \
+ or fill up first.",
+ plural(fuel, "jump")
+ )
+ });
+ return Transition::None;
+ }
+ let days = g.route_days(ctx.star, &route);
+ let name = g.sites[to].name.clone();
+ ctx.day += days;
+ ctx.hold.fuel -= jumps;
+ ctx.visited.extend(route.iter().copied());
+ ctx.enter_system(to);
+ ctx.arrival_note = Some(format!(
+ "{} and {} later, you come out of the jump at the edge of the {name} system, {{where}}.",
+ capitalise(&plural(jumps, "jump")),
+ days_text(days)
+ ));
+ Transition::Pop
+ }
+
+ /// Calls a tow, if you're stranded.
+ fn tow(&mut self, ctx: &mut Ctx) -> Transition {
+ if !ctx.stranded() {
+ self.report = Some(if ctx.hold.fuel > 0 {
+ "You've fuel enough to jump. No one tows a ship that can fly.".into()
+ } else {
+ "Fuel's for sale here, at a station or a city's shipyard. No tow will come for that."
+ .into()
+ });
+ return Transition::None;
+ }
+ let g = &ctx.galaxy;
+ let Some(route) = g.nearest(ctx.star, |i| g.sites[i].kind.sells_fuel()) else {
+ self.report = Some("No one answers.".into());
+ return Transition::None;
+ };
+ let to = *route.last().unwrap();
+ let fee = trade::tow_fee(route.len());
+ // Towing is slow: twice as long as jumping yourself.
+ let days = 2.0 * g.route_days(ctx.star, &route);
+ let name = g.sites[to].name.clone();
+ ctx.day += days;
+ ctx.visited.extend(route.iter().copied());
+ let owed = ctx.hold.charge(fee);
+ ctx.enter_system(to);
+ if let Some(place) = ctx.system.fuel_place() {
+ ctx.location = place;
+ }
+ let bill = if owed == 0 {
+ format!("The fee is {fee} ¤, and you pay it.")
+ } else {
+ format!(
+ "The fee is {fee} ¤. You pay what you can, and owe {owed} ¤; \
+ what you earn goes to it first."
+ )
+ };
+ ctx.arrival_note = Some(format!(
+ "{} later the tow casts you off in the {name} system, {{where}}. {bill}",
+ capitalise(&days_text(days))
+ ));
+ Transition::Pop
+ }
+}
+
+impl Scene for GalaxyMap {
+ fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition {
+ match key.code {
+ KeyCode::Up | KeyCode::Char('w' | 'k') => self.step(Vec2::new(0.0, 1.0), ctx),
+ KeyCode::Down | KeyCode::Char('s' | 'j') => self.step(Vec2::new(0.0, -1.0), ctx),
+ KeyCode::Left | KeyCode::Char('a' | 'h') => self.step(Vec2::new(-1.0, 0.0), ctx),
+ KeyCode::Right | KeyCode::Char('d' | 'l') => self.step(Vec2::new(1.0, 0.0), ctx),
+ KeyCode::Tab => self.next_lane(ctx),
+ KeyCode::Char('.') => self.select(ctx.star, ctx),
+ KeyCode::Enter => return self.travel(ctx),
+ KeyCode::Char('t') => return self.tow(ctx),
+ KeyCode::Char('m') => return Transition::Replace(vec![Box::new(SystemMap::new(ctx))]),
+ KeyCode::Esc | KeyCode::Char('g' | 'q') => return Transition::Pop,
+ _ => {}
+ }
+ Transition::None
+ }
+
+ fn render(&self, frame: &mut Frame, ctx: &Ctx) {
+ let area = frame.area();
+ let (map, panel) = if area.width >= SIDE_BY_SIDE {
+ let [map, panel] =
+ Layout::horizontal([Constraint::Fill(1), Constraint::Length(PANEL_WIDTH)])
+ .areas(area);
+ (map, panel)
+ } else {
+ let rows = (area.height / 2).min(16);
+ let [map, panel] =
+ Layout::vertical([Constraint::Fill(1), Constraint::Length(rows)]).areas(area);
+ (map, panel)
+ };
+ let charted = ctx.galaxy.charted(&ctx.visited);
+ let route = self.route(ctx, &charted).unwrap_or_default();
+ if map.width >= 4 && map.height >= 2 {
+ let view = View::new(&ctx.galaxy, map, ctx.galaxy.sites[self.selected].pos);
+ self.draw_map(frame, &view, ctx, &charted, &route);
+ if map.width >= 60 && map.height >= 18 && !view.shows_all(&ctx.galaxy) {
+ draw_inset(frame.buffer_mut(), map, &view, ctx, self.selected);
+ }
+ }
+ self.draw_panel(frame, panel, ctx, &charted, &route);
+ }
+}
+
+/// Which part of the galaxy is on screen: a light-year to a cell across,
+/// two to a cell down.
+struct View {
+ area: Rect,
+ left: f64,
+ top: f64,
+}
+
+impl View {
+ /// Centred on `focus`, as far as that doesn't scroll off into empty
+ /// space.
+ fn new(galaxy: &Galaxy, area: Rect, focus: Vec2) -> View {
+ let (low, high) = galaxy.bounds();
+ let margin = 8.0;
+ let w = f64::from(area.width);
+ let h = f64::from(area.height) * 2.0;
+ let fit = |focus: f64, low: f64, high: f64, span: f64| {
+ if high - low + 2.0 * margin <= span {
+ (low + high) / 2.0
+ } else {
+ focus.clamp(low - margin + span / 2.0, high + margin - span / 2.0)
+ }
+ };
+ let cx = fit(focus.x, low.x, high.x, w);
+ let cy = fit(focus.y, low.y, high.y, h);
+ View { area, left: cx - w / 2.0, top: cy + h / 2.0 }
+ }
+
+ fn right(&self) -> f64 {
+ self.left + f64::from(self.area.width)
+ }
+
+ fn bottom(&self) -> f64 {
+ self.top - 2.0 * f64::from(self.area.height)
+ }
+
+ /// The cell a point falls in, if it's on screen.
+ fn cell(&self, p: Vec2) -> Option<(u16, u16)> {
+ let col = (p.x - self.left).floor();
+ let row = ((self.top - p.y) / 2.0).floor();
+ let inside = col >= 0.0
+ && row >= 0.0
+ && col < f64::from(self.area.width)
+ && row < f64::from(self.area.height);
+ inside.then(|| (self.area.x + col as u16, self.area.y + row as u16))
+ }
+
+ fn shows_all(&self, galaxy: &Galaxy) -> bool {
+ let (low, high) = galaxy.bounds();
+ low.x >= self.left && high.x < self.right() && high.y <= self.top && low.y > self.bottom()
+ }
+}
+
+/// Which people's territory a point is in, if any, and whether it's near
+/// a border.
+fn territory(galaxy: &Galaxy, p: Vec2) -> Option<(Culture, bool)> {
+ let mut nearest = (f64::MAX, Culture::Qaari);
+ for s in &galaxy.sites {
+ let d = s.pos.distance(p);
+ if d < nearest.0 {
+ nearest = (d, s.culture);
+ }
+ }
+ if nearest.0 > REACH {
+ return None;
+ }
+ let other = galaxy
+ .sites
+ .iter()
+ .filter(|s| s.culture != nearest.1)
+ .map(|s| s.pos.distance(p))
+ .fold(f64::MAX, f64::min);
+ Some((nearest.1, other - nearest.0 < 1.6))
+}
+
+impl GalaxyMap {
+ fn draw_map(
+ &self,
+ frame: &mut Frame,
+ view: &View,
+ ctx: &Ctx,
+ charted: &[bool],
+ route: &[usize],
+ ) {
+ let g = &ctx.galaxy;
+ let area = view.area;
+
+ // Territory: a faint dot in every other cell, and every cell along
+ // a border. Each people's dots are drawn together, in their colour.
+ let mut tint: Vec<(Color, Vec<(f64, f64)>)> = Vec::new();
+ for c in Culture::ALL {
+ let rgb = people_rgb(c);
+ tint.push((sky::shade(rgb, 0.5), Vec::new()));
+ tint.push((sky::shade(rgb, 0.85), Vec::new()));
+ }
+ for row in 0..area.height {
+ for col in 0..area.width {
+ let p = Vec2::new(
+ view.left + f64::from(col) + 0.25,
+ view.top - 2.0 * f64::from(row) - 0.75,
+ );
+ let Some((culture, border)) = territory(g, p) else { continue };
+ if border || (col + row) % 2 == 0 {
+ tint[culture as usize * 2 + usize::from(border)].1.push((p.x, p.y));
+ }
+ }
+ }
+ let on_route = |a: usize, b: usize| {
+ let mut stops = vec![ctx.star];
+ stops.extend(route);
+ stops.windows(2).any(|w| (w[0] == a && w[1] == b) || (w[0] == b && w[1] == a))
+ };
+ let canvas = Canvas::default()
+ .marker(Marker::Braille)
+ .background_color(Color::Reset)
+ .x_bounds([view.left, view.right()])
+ .y_bounds([view.bottom(), view.top])
+ .paint(|c| {
+ for (color, coords) in &tint {
+ c.draw(&Points { coords, color: *color });
+ }
+ c.layer();
+ for (i, s) in g.sites.iter().enumerate() {
+ for &j in s.lanes.iter().filter(|&&j| j > i) {
+ let colour = match (charted[i], charted[j]) {
+ _ if on_route(i, j) => continue,
+ (true, true) => LANE,
+ (true, false) | (false, true) => FAINT_LANE,
+ (false, false) => continue,
+ };
+ let t = &g.sites[j];
+ c.draw(&Lane::new(s.pos.x, s.pos.y, t.pos.x, t.pos.y, colour));
+ }
+ }
+ c.layer();
+ let mut at = g.sites[ctx.star].pos;
+ for &next in route {
+ let to = g.sites[next].pos;
+ c.draw(&Lane::new(at.x, at.y, to.x, to.y, ACCENT));
+ at = to;
+ }
+ });
+ frame.render_widget(canvas, area);
+ self.draw_stars(frame.buffer_mut(), view, ctx, charted, route);
+ }
+
+ /// Each star, then names, most important first, wherever they fit.
+ fn draw_stars(
+ &self,
+ buf: &mut Buffer,
+ view: &View,
+ ctx: &Ctx,
+ charted: &[bool],
+ route: &[usize],
+ ) {
+ let g = &ctx.galaxy;
+ let mut taken: HashSet<(u16, u16)> = HashSet::new();
+ for (i, s) in g.sites.iter().enumerate() {
+ let Some(cell) = view.cell(s.pos) else { continue };
+ let rgb = people_rgb(s.culture);
+ let (glyph, style) = if i == ctx.star {
+ ("@", Style::new().fg(Color::White).bold())
+ } else if g.is_seat(i) && (charted[i] || ctx.visited.contains(&i)) {
+ ("◉", Style::new().fg(sky::rgb(rgb)).bold())
+ } else if g.is_seat(i) {
+ ("◉", Style::new().fg(sky::shade(rgb, 0.7)))
+ } else if ctx.visited.contains(&i) {
+ ("●", Style::new().fg(sky::rgb(rgb)))
+ } else if charted[i] {
+ ("○", Style::new().fg(sky::rgb(rgb)))
+ } else {
+ ("·", Style::new().fg(sky::shade(rgb, 0.75)))
+ };
+ buf[cell].set_symbol(glyph).set_style(style);
+ taken.insert(cell);
+ if i == self.selected {
+ for (dx, bracket) in [(-1i32, "["), (1, "]")] {
+ let x = i32::from(cell.0) + dx;
+ if x >= i32::from(view.area.x) && x < i32::from(view.area.right()) {
+ buf[(x as u16, cell.1)].set_symbol(bracket).set_fg(ACCENT);
+ taken.insert((x as u16, cell.1));
+ }
+ }
+ }
+ }
+
+ // Who gets a name, in order: you, the choice, the way there, the
+ // seats, where you've been, what's charted.
+ let mut order: Vec<usize> = vec![ctx.star, self.selected];
+ order.extend(route);
+ order.extend((0..g.sites.len()).filter(|&i| g.is_seat(i)));
+ order.extend(ctx.visited.iter().copied());
+ order.extend((0..g.sites.len()).filter(|&i| charted[i]));
+ let mut named = HashSet::new();
+ for i in order {
+ if !named.insert(i) {
+ continue;
+ }
+ let s = &g.sites[i];
+ let Some((col, row)) = view.cell(s.pos) else { continue };
+ let known = charted[i] || g.is_seat(i) || ctx.visited.contains(&i);
+ let text = if known { s.name.clone() } else { "uncharted".to_string() };
+ let fg = if i == self.selected {
+ ACCENT
+ } else if i == ctx.star {
+ Color::White
+ } else if ctx.visited.contains(&i) && !s.kind.sells_fuel() {
+ NO_FUEL
+ } else if ctx.visited.contains(&i) {
+ Color::Rgb(200, 204, 216)
+ } else if charted[i] {
+ Color::Rgb(148, 152, 170)
+ } else {
+ sky::shade(people_rgb(s.culture), 0.7)
+ };
+ let start = col + 2;
+ let width = text.chars().count() as u16;
+ let end = start.saturating_add(width).min(view.area.right());
+ if start >= end {
+ continue;
+ }
+ let must = i == ctx.star || i == self.selected;
+ let clear = (start.saturating_sub(1)..end + 1).all(|x| !taken.contains(&(x, row)));
+ if !(must || clear) {
+ continue;
+ }
+ let shown: String = text.chars().take(usize::from(end - start)).collect();
+ buf.set_string(start, row, shown, Style::new().fg(fg));
+ for x in start..end {
+ taken.insert((x, row));
+ }
+ }
+ }
+
+ fn draw_panel(
+ &self,
+ frame: &mut Frame,
+ area: Rect,
+ ctx: &Ctx,
+ charted: &[bool],
+ route: &[usize],
+ ) {
+ let block = Block::bordered()
+ .title(format!(" star map · day {:.1} ", ctx.day))
+ .title_style(Style::new().fg(ACCENT).bold())
+ .border_style(Style::new().fg(DIM));
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+ let mut hints = vec![("←↑↓→", "choose"), ("tab", "lanes"), ("enter", "jump")];
+ if ctx.stranded() {
+ hints.push(("t", "tow"));
+ }
+ hints.extend([(".", "where you are"), ("m", "system map"), ("esc", "back")]);
+ let hints = keys::packed(&hints, inner.width);
+ let [about, keys] =
+ Layout::vertical([Constraint::Fill(1), Constraint::Length(2 + hints.len() as u16)])
+ .areas(inner);
+ let width = usize::from(about.width);
+ frame.render_widget(
+ Paragraph::new(self.about(ctx, charted, route, width)).wrap(Wrap { trim: false }),
+ about,
+ );
+
+ let mut legend = vec![
+ Line::from(vec![
+ Span::raw("@").fg(Color::White).bold(),
+ Span::raw(" you ").fg(DIM),
+ Span::raw("◉").fg(DIM),
+ Span::raw(" seat ").fg(DIM),
+ Span::raw("●").fg(DIM),
+ Span::raw(" been ").fg(DIM),
+ Span::raw("○").fg(DIM),
+ Span::raw(" charted ").fg(DIM),
+ Span::raw("·").fg(DIM),
+ Span::raw(" not").fg(DIM),
+ ]),
+ Line::from(
+ Culture::ALL
+ .into_iter()
+ .flat_map(|c| {
+ [
+ Span::raw("■ ").fg(sky::rgb(people_rgb(c))),
+ Span::raw(format!("{} ", c.adjective())).fg(DIM),
+ ]
+ })
+ .collect::<Vec<_>>(),
+ ),
+ ];
+ legend.extend(hints);
+ frame.render_widget(Paragraph::new(legend), keys);
+ }
+
+ /// What's known about the chosen system, and the way there.
+ fn about(
+ &self,
+ ctx: &Ctx,
+ charted: &[bool],
+ route: &[usize],
+ width: usize,
+ ) -> Vec<Line<'static>> {
+ let g = &ctx.galaxy;
+ let i = self.selected;
+ let site = &g.sites[i];
+ let h = &ctx.hold;
+ let dim = Style::new().fg(DIM);
+ let plain = Style::new();
+ let label = |text: &str| vec![Span::raw(format!("{text:<8}")).fg(DIM)];
+
+ let gauge: String =
+ (0..h.fuel_capacity).map(|k| if k < h.fuel { '◆' } else { '◇' }).collect();
+ let mut lines = vec![Line::from(vec![
+ Span::raw(format!("{:<8}", "fuel")).fg(DIM),
+ Span::raw(gauge).fg(FUEL),
+ Span::raw(format!(" {} of {}", h.fuel, plural(h.fuel_capacity, "jump"))),
+ ])];
+ if h.debt > 0 {
+ lines.push(Line::from(vec![
+ Span::raw(format!("{:<8}", "owed")).fg(DIM),
+ Span::raw(format!("{} ¤, paid from what you earn", h.debt)),
+ ]));
+ }
+ if ctx.stranded() {
+ lines.push(Line::default());
+ let (to, fee) = match g.nearest(ctx.star, |s| g.sites[s].kind.sells_fuel()) {
+ Some(r) => (g.sites[*r.last().unwrap()].name.clone(), trade::tow_fee(r.len())),
+ None => ("nowhere".into(), 0),
+ };
+ lines.extend(wrap(
+ vec![Span::raw("Stranded. ").fg(NO_FUEL).bold()],
+ &[(
+ format!(
+ "Your tank's dry and no one here sells fuel. [t] calls a tow to {to}, \
+ for {fee} ¤; what you can't pay, you'll owe."
+ ),
+ plain,
+ )],
+ width,
+ ));
+ }
+ lines.push(Line::default());
+
+ let known = charted[i] || g.is_seat(i) || ctx.visited.contains(&i);
+ let title = if known { site.name.clone() } else { "An uncharted system".into() };
+ lines.push(Line::from(title).fg(ACCENT).bold());
+ let whose = site.culture.name();
+ let what = if ctx.visited.contains(&i) {
+ match site.kind {
+ SystemKind::Seat => format!("the seat of {whose}"),
+ kind => format!("{} of {whose}", kind.name()),
+ }
+ } else if g.is_seat(i) {
+ format!("the seat of {whose}; you've not been there")
+ } else if charted[i] {
+ format!("in the space of {whose}; you've not been there")
+ } else {
+ format!("in the space of {whose}, past what you've charted")
+ };
+ let head = vec![Span::raw("■ ").fg(sky::rgb(people_rgb(site.culture)))];
+ lines.extend(wrap(head, &[(what, plain)], width));
+ // The way there.
+ if i == ctx.star {
+ let lanes: Vec<String> = site.lanes.iter().map(|&l| g.sites[l].name.clone()).collect();
+ lines.extend(wrap(
+ label("lanes"),
+ &[(format!("to {}", join_names(&lanes)), plain)],
+ width,
+ ));
+ } else if !charted[i] {
+ lines.extend(wrap(
+ Vec::new(),
+ &[("Uncharted: jump closer to learn the way.".into(), dim)],
+ width,
+ ));
+ } else if route.is_empty() {
+ lines.extend(wrap(Vec::new(), &[("No charted route there.".into(), dim)], width));
+ } else {
+ let jumps = route.len() as u32;
+ let days = g.route_days(ctx.star, route);
+ let short = h.fuel < jumps;
+ let fuel_text = format!("· {} of fuel", plural(jumps, "jump"));
+ lines.extend(wrap(
+ label("route"),
+ &[
+ (format!("{}, {}", plural(jumps, "jump"), days_text(days)), plain),
+ (
+ fuel_text,
+ if short { Style::new().fg(NO_FUEL) } else { Style::new().fg(FUEL) },
+ ),
+ ],
+ width,
+ ));
+ if route.len() > 1 {
+ let via: Vec<String> =
+ route[..route.len() - 1].iter().map(|&s| g.sites[s].name.clone()).collect();
+ lines.extend(wrap(label("via"), &[(via.join(" → "), plain)], width));
+ }
+ if short {
+ lines.extend(wrap(
+ Vec::new(),
+ &[(
+ format!("You've fuel for {}.", plural(h.fuel, "jump")),
+ Style::new().fg(NO_FUEL),
+ )],
+ width,
+ ));
+ }
+ }
+ if let Some(report) = &self.report {
+ lines.push(Line::default());
+ lines.extend(wrap(Vec::new(), &[(report.clone(), Style::new().fg(ACCENT))], width));
+ }
+ lines.push(Line::default());
+
+ // What it is, and what's there if you've been.
+ if known {
+ lines.extend(wrap(
+ label("star"),
+ &[
+ (names::a(site.star.name), plain),
+ (format!("· {:.0} ly from here", g.distance(ctx.star, i)), dim),
+ ],
+ width,
+ ));
+ }
+ if let Some(neighbour) = site.neighbour.filter(|_| ctx.visited.contains(&i)) {
+ lines.extend(wrap(
+ label("border"),
+ &[(format!("with {}, who settle here too", neighbour.name()), plain)],
+ width,
+ ));
+ }
+
+ let system = if i == ctx.star { Some(&ctx.system) } else { self.known.as_ref() };
+ if let Some(s) = system {
+ lines.extend(wrap(Vec::new(), &[(site.kind.blurb().to_string(), dim)], width));
+ lines.extend(contents(s, ctx, width));
+ } else if charted[i] {
+ lines.extend(wrap(
+ Vec::new(),
+ &[("What's there, you'll know when you've been. Ask in a tavern.".into(), dim)],
+ width,
+ ));
+ }
+ lines
+ }
+}
+
+/// What's in a system you've been to: its capitals, stations, fuel and
+/// wrecks.
+fn contents(s: &System, ctx: &Ctx, width: usize) -> Vec<Line<'static>> {
+ let plain = Style::new();
+ let dim = Style::new().fg(DIM);
+ let label = |text: &str| vec![Span::raw(format!("{text:<8}")).fg(DIM)];
+ let mut lines = Vec::new();
+ let capitals: Vec<String> = (0..s.places.len())
+ .filter(|&p| matches!(s.landing(p), Landing::Capital(_)))
+ .map(|p| s.places[p].name.clone())
+ .collect();
+ let worlds = s.planets.len();
+ let mut parts = vec![(plural(worlds as u32, "world"), plain)];
+ match capitals.len() {
+ 0 => parts.push(("· no capital".into(), dim)),
+ _ => parts.push((
+ format!("· ⌂ {}", join_names(&capitals)),
+ Style::new().fg(Color::Rgb(130, 214, 140)),
+ )),
+ }
+ lines.extend(wrap(label("worlds"), &parts, width));
+ if !s.stations.is_empty() {
+ let names: Vec<String> = s.stations.iter().map(|st| st.name.clone()).collect();
+ lines.extend(wrap(label("docks"), &[(join_names(&names), plain)], width));
+ }
+ let fuel = if s.sells_fuel() {
+ ("for sale".to_string(), Style::new().fg(FUEL))
+ } else {
+ ("none for sale".to_string(), Style::new().fg(NO_FUEL))
+ };
+ lines.extend(wrap(label("fuel"), &[fuel], width));
+ let beacons = s.beacons(ctx.day, &ctx.stripped);
+ if !beacons.is_empty() {
+ let failing = beacons.iter().filter(|w| w.leaves - ctx.day <= FAILING).count();
+ let mut parts = vec![(
+ format!("{} calling", plural(beacons.len() as u32, "beacon")),
+ Style::new().fg(Color::Rgb(255, 120, 100)),
+ )];
+ if failing > 0 {
+ parts.push((format!("· {failing} failing"), Style::new().fg(FAINT_WRECK)));
+ }
+ lines.extend(wrap(label("wrecks"), &parts, width));
+ }
+ if s.belt.density > 1.0 {
+ lines.extend(wrap(
+ label("belt"),
+ &[(format!("{}: dense, and rich in ore", s.belt.name), plain)],
+ width,
+ ));
+ }
+ lines
+}
+
+/// The whole galaxy, small, in the map's top-left corner: each people's
+/// space, where you are, the choice, and the part the map shows.
+fn draw_inset(buf: &mut Buffer, map: Rect, view: &View, ctx: &Ctx, selected: usize) {
+ let g = &ctx.galaxy;
+ let (w, h) = INSET;
+ let outer = Rect::new(map.x, map.y, w + 2, h + 2);
+ let block = Block::bordered()
+ .title(" galaxy ")
+ .title_style(Style::new().fg(DIM))
+ .border_style(Style::new().fg(DIM));
+ let inner = block.inner(outer);
+ ratatui::widgets::Clear.render(outer, buf);
+ block.render(outer, buf);
+ let (low, high) = g.bounds();
+ let margin = 6.0;
+ let (low, high) = (low - Vec2::new(margin, margin), high + Vec2::new(margin, margin));
+ let to_cell = |p: Vec2| {
+ let col = ((p.x - low.x) / (high.x - low.x) * f64::from(w)).floor();
+ let row = ((high.y - p.y) / (high.y - low.y) * f64::from(h)).floor();
+ (col.clamp(0.0, f64::from(w - 1)) as u16, row.clamp(0.0, f64::from(h - 1)) as u16)
+ };
+ for row in 0..h {
+ for col in 0..w {
+ let p = Vec2::new(
+ low.x + (f64::from(col) + 0.5) / f64::from(w) * (high.x - low.x),
+ high.y - (f64::from(row) + 0.5) / f64::from(h) * (high.y - low.y),
+ );
+ let cell = &mut buf[(inner.x + col, inner.y + row)];
+ cell.set_symbol(" ");
+ if let Some((c, _)) = territory(g, p) {
+ cell.set_bg(sky::shade(people_rgb(c), 0.28));
+ }
+ }
+ }
+ // The view, as corners.
+ let (a, b) =
+ (to_cell(Vec2::new(view.left, view.top)), to_cell(Vec2::new(view.right(), view.bottom())));
+ for (x, y, ch) in [(a.0, a.1, "┌"), (b.0, a.1, "┐"), (a.0, b.1, "└"), (b.0, b.1, "┘")] {
+ buf[(inner.x + x, inner.y + y)].set_symbol(ch).set_fg(Color::Rgb(200, 204, 216));
+ }
+ let (x, y) = to_cell(g.sites[selected].pos);
+ buf[(inner.x + x, inner.y + y)].set_symbol("+").set_style(Style::new().fg(ACCENT).bold());
+ let (x, y) = to_cell(g.sites[ctx.star].pos);
+ buf[(inner.x + x, inner.y + y)].set_symbol("@").set_style(Style::new().fg(Color::White).bold());
+}
+
+/// "1 jump", "3 jumps".
+fn plural(n: u32, what: &str) -> String {
+ if n == 1 { format!("1 {what}") } else { format!("{n} {what}s") }
+}
+
+fn days_text(days: f64) -> String {
+ format!("{days:.1} days")
+}
+
+/// "A", "A and B", "A, B and C".
+fn join_names(names: &[String]) -> String {
+ match names {
+ [] => String::new(),
+ [one] => one.clone(),
+ [rest @ .., last] => format!("{} and {last}", rest.join(", ")),
+ }
+}
+
+fn capitalise(s: &str) -> String {
+ let mut chars = s.chars();
+ chars.next().map_or(String::new(), |c| c.to_uppercase().chain(chars).collect())
+}
+
+#[cfg(test)]
+mod tests {
+ use ratatui::crossterm::event::KeyModifiers;
+
+ use super::*;
+ use crate::scenes::tests::test_ctx;
+
+ fn press(map: &mut GalaxyMap, ctx: &mut Ctx, code: KeyCode) -> Transition {
+ map.key(KeyEvent::new(code, KeyModifiers::NONE), ctx)
+ }
+
+ #[test]
+ fn jumping_along_a_lane_costs_fuel_and_days() {
+ let mut ctx = test_ctx();
+ let mut map = GalaxyMap::new(&ctx);
+ let start = ctx.star;
+ press(&mut map, &mut ctx, KeyCode::Tab);
+ let to = map.selected;
+ assert!(ctx.galaxy.sites[start].lanes.contains(&to));
+ let days = ctx.galaxy.jump_days(start, to);
+ ctx.departed = Some(0.0);
+ assert!(matches!(press(&mut map, &mut ctx, KeyCode::Enter), Transition::Pop));
+ assert_eq!(ctx.star, to);
+ assert_eq!(ctx.departed, None, "no wreck here waits for you");
+ assert_eq!(ctx.system.name, ctx.galaxy.sites[to].name);
+ assert_eq!(ctx.location, ctx.system.edge_place(), "you come out at the edge");
+ assert_eq!(ctx.hold.fuel, crate::app::Hold::BASE_FUEL - 1);
+ assert!((ctx.day - days).abs() < 1e-9);
+ assert!(ctx.visited.contains(&to));
+ assert!(ctx.arrival_note.as_ref().unwrap().contains("1 jump and"));
+ }
+
+ #[test]
+ fn you_can_only_plot_through_charted_space_and_on_the_fuel_you_have() {
+ let mut ctx = test_ctx();
+ let g = &ctx.galaxy;
+ let charted = g.charted(&ctx.visited);
+ let far = (0..g.sites.len()).find(|&i| !charted[i]).unwrap();
+ let mut map = GalaxyMap::new(&ctx);
+ map.select(far, &ctx);
+ assert!(matches!(press(&mut map, &mut ctx, KeyCode::Enter), Transition::None));
+ assert!(map.report.as_ref().unwrap().contains("uncharted"));
+
+ let next = ctx.galaxy.sites[ctx.star].lanes[0];
+ map.select(next, &ctx);
+ ctx.hold.fuel = 0;
+ assert!(matches!(press(&mut map, &mut ctx, KeyCode::Enter), Transition::None));
+ assert!(map.report.as_ref().unwrap().contains("dry"));
+ assert_eq!(ctx.day, 0.0);
+ }
+
+ #[test]
+ fn stranded_ships_are_towed_and_owe_for_it() {
+ let mut ctx = test_ctx();
+ let g = &ctx.galaxy;
+ let dead = (0..g.sites.len()).find(|&i| !g.sites[i].kind.sells_fuel()).unwrap();
+ ctx.enter_system(dead);
+ ctx.hold.fuel = 0;
+ ctx.hold.scrip = 20;
+ assert!(ctx.stranded());
+ let mut map = GalaxyMap::new(&ctx);
+ assert!(matches!(press(&mut map, &mut ctx, KeyCode::Char('t')), Transition::Pop));
+ assert!(ctx.system.sells_fuel());
+ assert!(ctx.system.sells_fuel_at(ctx.location), "dropped where fuel's sold");
+ assert_eq!(ctx.hold.scrip, 0);
+ assert!(ctx.hold.debt > 0);
+ assert!(ctx.day > 0.0);
+ assert!(!ctx.stranded());
+ // No tow for a ship that can buy fuel where it is.
+ let mut map = GalaxyMap::new(&ctx);
+ assert!(matches!(press(&mut map, &mut ctx, KeyCode::Char('t')), Transition::None));
+ }
+
+ #[test]
+ fn arrows_move_between_stars() {
+ let ctx = test_ctx();
+ let mut map = GalaxyMap::new(&ctx);
+ let from = ctx.galaxy.sites[map.selected].pos;
+ map.step(Vec2::new(1.0, 0.0), &ctx);
+ if map.selected != ctx.star {
+ assert!(ctx.galaxy.sites[map.selected].pos.x > from.x);
+ }
+ map.select(ctx.star, &ctx);
+ assert_eq!(map.selected, ctx.star);
+ }
+
+ #[test]
+ fn names_join_naturally() {
+ let names = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
+ assert_eq!(join_names(&names(&["A"])), "A");
+ assert_eq!(join_names(&names(&["A", "B", "C"])), "A, B and C");
+ assert_eq!(plural(1, "jump"), "1 jump");
+ }
+}