//! Flying around one place: the local map, the ship, the laser, and a side //! panel with a radar and your status. The rules live in `space::flight`; //! this turns keys into `Controls` and draws the result. //! //! On terminals that report key releases, you hold keys to thrust, turn and //! fire. Elsewhere there's no telling when a key is let go, so thrust is a //! throttle you step up and down, each turn press swings you round 22.5°, //! and each laser press fires for a moment. use std::f64::consts::{FRAC_PI_2, PI}; use ratatui::Frame; use ratatui::buffer::Buffer; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind}; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Style, Stylize}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Padding, Paragraph, Wrap}; use super::aboard::Aboard; use super::character::Sheet; use super::derelict::Derelict; use super::galaxy_map::GalaxyMap; use super::keys; use super::pause::{Choice, Pause}; use super::port::{FUEL, Port}; use super::sky::{self, Surface, View, mix}; use super::system_map::SystemMap; use super::{Scene, Transition}; use crate::app::{Ctx, Hold, TICKS_PER_SECOND}; use crate::seed; use crate::space::Vec2; use crate::space::flight::{ Controls, Event, FLOTSAM_LIFE, Flight, LANDING_SPEED, SPARK_LIFE, THRUST, TURN_RATE, }; use crate::space::locale::{self, BACKDROP_PARALLAX, Size}; use crate::space::system::Landing; use crate::trade; const DT: f64 = 1.0 / TICKS_PER_SECOND as f64; const PANEL_WIDTH: u16 = 30; /// Below this width the panel gives way to a one-line status bar. const PANEL_MIN_SCREEN: u16 = 76; /// Throttle steps, for terminals without held keys. const THROTTLE_STEPS: u8 = 4; /// How far one turn press swings you, without held keys. const TURN_STEP: f64 = PI / 8.0; /// How long one laser press fires for, without held keys. const FIRE_TICKS: u64 = TICKS_PER_SECOND as u64 * 6 / 10; /// Seconds a message stays up. const MESSAGE_SECONDS: u64 = 6; const HULL: Color = Color::Rgb(112, 118, 140); const ROCK: (u8, u8, u8) = (150, 138, 124); const BEAM: Color = Color::Rgb(255, 110, 220); const DIM: Color = Color::Rgb(90, 94, 112); const ACCENT: Color = Color::Rgb(240, 200, 110); /// Why you're out of the pilot's seat, walking about the ship: that scene /// is on top of this one until you sit back down. #[derive(Clone, Copy, Debug, PartialEq)] enum Walking { /// Landed or docked. Landed, /// Got up while flying slowly, and the ship holds station. Adrift, } /// What tab points you at. #[derive(Clone, Copy, Debug, PartialEq)] enum Target { /// Whichever rock with ore in it is nearest right now. NearestOre, /// The planet, moon or station in the middle. Middle, /// A wreck, by index into `locale.wrecks`. Wreck(usize), } pub struct FlightScene { flight: Flight, /// Which system, place and day `flight` was built for. When the map /// moves you on, these fall out of date and the scene rebuilds. star: usize, place: usize, day: f64, throttle: u8, /// Turning still to do, in radians, from turn presses. pending_turn: f64, /// Turning to face backwards and thrusting until stopped. braking: bool, fire_until: u64, target: Option, messages: Vec<(String, u64)>, walking: Option, /// The menu `esc` opens. Everything holds still while it's open. pause: Option, } impl FlightScene { pub fn new(ctx: &Ctx) -> FlightScene { let mut scene = FlightScene { flight: arrive(ctx), star: ctx.star, place: ctx.location, day: ctx.day, throttle: 0, pending_turn: 0.0, braking: false, fire_until: 0, target: None, messages: Vec::new(), walking: None, pause: None, }; let l = &scene.flight.locale; let text = format!("You're {}.", l.what); scene.say(text, ctx); scene } fn say(&mut self, text: String, ctx: &Ctx) { self.messages.retain(|(t, _)| *t != text); self.messages.push((text, ctx.ticks)); if self.messages.len() > 6 { self.messages.remove(0); } } /// If the map has moved you on (or let time pass), arrives at the new /// place. fn catch_up(&mut self, ctx: &mut Ctx) { if self.star == ctx.star && self.place == ctx.location && self.day == ctx.day { return; } let days = ctx.day - self.day; let moved = self.place != ctx.location; let jumped = self.star != ctx.star; self.flight = arrive(ctx); self.star = ctx.star; self.place = ctx.location; self.day = ctx.day; self.throttle = 0; self.pending_turn = 0.0; self.braking = false; self.target = None; self.messages.clear(); let l = &self.flight.locale; let note = ctx.arrival_note.take(); let text = if let Some(note) = note { note.replace("{where}", &l.what) } else if jumped { format!( "{} later you arrive in the {} system, {}.", days_text(days), ctx.system.name, l.what ) } else if moved { format!("{} later you arrive {}.", days_text(days), l.what) } else if (days - 1.0).abs() < 0.05 { "A day passes, and the rocks drift on.".to_string() } else { format!("{} pass, and the rocks drift on.", days_text(days)) }; self.say(text, ctx); if jumped && ctx.stranded() { let text = "Your tank's dry, and no one here sells fuel. [g] the star map, to call a tow."; self.say(text.to_string(), ctx); } } /// Back in the pilot's seat, if you'd got up. fn sit_down(&mut self, ctx: &mut Ctx) { let Some(walking) = self.walking.take() else { return }; ctx.landed = false; let name = &ctx.system.places[self.place].name; let text = match walking { Walking::Landed if matches!(ctx.system.landing(self.place), Landing::Market(_)) => { format!("You undock from {name}.") } Walking::Landed => format!("You lift off from {name}."), Walking::Adrift => "You sit back down and take the controls.".to_string(), }; self.say(text, ctx); } fn held(ctx: &Ctx, codes: &[KeyCode]) -> bool { codes.iter().any(|&c| ctx.is_held(c)) } fn controls(&mut self, ctx: &Ctx) -> Controls { let mut c = Controls::default(); if ctx.keyboard.key_release { let left = Self::held(ctx, &[KeyCode::Left, KeyCode::Char('a')]); let right = Self::held(ctx, &[KeyCode::Right, KeyCode::Char('d')]); let thrust = Self::held(ctx, &[KeyCode::Up, KeyCode::Char('w')]); if left || right || thrust { self.braking = false; } if Self::held(ctx, &[KeyCode::Down, KeyCode::Char('s')]) { self.braking = true; } c.turn = f64::from(u8::from(right)) - f64::from(u8::from(left)); c.thrust = f64::from(u8::from(thrust)); c.firing = Self::held(ctx, &[KeyCode::Char(' '), KeyCode::Char('f')]); } else { let step = TURN_RATE * DT; c.turn = (self.pending_turn / step).clamp(-1.0, 1.0); self.pending_turn -= c.turn * step; c.thrust = f64::from(self.throttle) / f64::from(THROTTLE_STEPS); c.firing = ctx.ticks < self.fire_until; } if self.braking { let speed = self.flight.speed(); if speed < 0.3 { // Close enough: hold station. self.flight.ship.vel = Vec2::ZERO; self.braking = false; self.throttle = 0; } else { c.retro = true; // Full thrust against the drift, easing off on the last tick // so as not to overshoot. let enough = speed / (THRUST * DT); c.thrust = if self.flight.facing_retro() { enough.min(1.0) } else { 0.0 }; } } c } fn key_fallback(&mut self, code: KeyCode, ctx: &Ctx) -> bool { match code { KeyCode::Up | KeyCode::Char('w') => { self.throttle = (self.throttle + 1).min(THROTTLE_STEPS); self.braking = false; } KeyCode::Down | KeyCode::Char('s') => { if self.throttle > 0 { self.throttle -= 1; } else { self.braking = true; } } KeyCode::Left | KeyCode::Char('a') => self.turn_by(-TURN_STEP), KeyCode::Right | KeyCode::Char('d') => self.turn_by(TURN_STEP), KeyCode::Char(' ' | 'f') => self.fire_until = ctx.ticks + FIRE_TICKS, KeyCode::Char('x') => self.throttle = 0, _ => return false, } true } fn turn_by(&mut self, angle: f64) { self.braking = false; self.pending_turn = (self.pending_turn + angle).clamp(-FRAC_PI_2, FRAC_PI_2); } fn land(&mut self, ctx: &mut Ctx) -> Transition { let f = &self.flight; let l = &f.locale; let fast = f.speed() > LANDING_SPEED; let refusal = if let Some(body) = &l.body { if !f.over_body() { format!("Fly over {} to land.", body.name) } else if body.kind.giant { format!( "{} has no surface to land on: it's {} all the way down.", body.name, body.kind.name ) } else if fast { format!("Too fast to land. Slow below {LANDING_SPEED:.0}.") } else { return self.touch_down(ctx); } } else if let Some(dock) = &l.dock { if !f.at_dock() { format!("Fly in close to {} to dock.", dock.name) } else if fast { format!("Too fast to dock. Slow below {LANDING_SPEED:.0}.") } else { return self.touch_down(ctx); } } else { "There's nowhere to land out here.".to_string() }; self.say(refusal, ctx); Transition::None } /// Lands, or docks. Outside a capital you get up from the controls, /// and the way into the city is at the other end of the ship; anywhere /// else there's no city to walk to, so you're straight out at the dock, /// or on the ground. fn touch_down(&mut self, ctx: &mut Ctx) -> Transition { self.stop(); self.walking = Some(Walking::Landed); ctx.landed = true; Transition::Push(landed(ctx, self.place)) } /// Gets up from the controls to walk about the ship, if you're going /// slowly enough to leave it holding station. fn get_up(&mut self, ctx: &mut Ctx) -> Transition { if self.flight.speed() > LANDING_SPEED { let text = format!("Too fast to leave the controls. Slow below {LANDING_SPEED:.0}."); self.say(text, ctx); return Transition::None; } let wreck = self.flight.wreck_alongside().map(|i| self.flight.locale.wrecks[i].wreck.clone()); self.stop(); self.walking = Some(Walking::Adrift); Transition::Push(Box::new(Aboard::adrift(&self.flight.locale.what, wreck))) } fn stop(&mut self) { self.flight.ship.vel = Vec2::ZERO; self.throttle = 0; self.braking = false; self.pending_turn = 0.0; } fn board(&mut self, ctx: &mut Ctx) -> Transition { let Some(i) = self.flight.wreck_alongside() else { let text = if self.flight.locale.wrecks.is_empty() { "There are no wrecks here." } else { "Fly alongside a wreck to board it." }; self.say(text.to_string(), ctx); return Transition::None; }; let wreck = &self.flight.locale.wrecks[i].wreck; if self.flight.speed() > LANDING_SPEED { let text = format!("Too fast to board. Slow below {LANDING_SPEED:.0}."); self.say(text, ctx); Transition::None } else if ctx.stripped.contains(&wreck.seed) { let text = format!("You've already stripped {}. Nothing's left aboard.", wreck.name); self.say(text, ctx); Transition::None } else { let seed = wreck.seed; ctx.stripped.insert(seed); self.stop(); Transition::Push(Box::new(Derelict::board(seed, ctx))) } } fn next_target(&mut self) { let l = &self.flight.locale; let mut targets = vec![]; if l.rocks.iter().any(|r| r.ore.is_some()) { targets.push(Target::NearestOre); } if l.body.is_some() || l.dock.is_some() { targets.push(Target::Middle); } targets.extend((0..l.wrecks.len()).map(Target::Wreck)); self.target = match self.target.and_then(|t| targets.iter().position(|&x| x == t)) { Some(i) if i + 1 < targets.len() => Some(targets[i + 1]), Some(_) => None, None => targets.first().copied(), }; } /// Where the target is and what to call it. fn target_info(&self) -> Option<(Vec2, f64, String)> { let l = &self.flight.locale; let ship = self.flight.ship.pos; match self.target? { Target::NearestOre => { let rock = l .rocks .iter() .filter(|r| r.ore.is_some()) .min_by(|a, b| a.pos.distance(ship).total_cmp(&b.pos.distance(ship)))?; let size = match rock.size { Size::Small => "small", Size::Medium => "", Size::Large => "large", }; let name = format!("{size} rock, {}", rock.ore.unwrap().name); Some((rock.pos, rock.size.radius(), name.trim_start().to_string())) } Target::Middle => match (&l.body, &l.dock) { (Some(b), _) => Some((Vec2::ZERO, b.radius, b.name.clone())), (None, Some(d)) => Some((Vec2::ZERO, locale::DOCK_RADIUS, d.name.clone())), (None, None) => None, }, Target::Wreck(i) => { let w = l.wrecks.get(i)?; Some((w.pos, 2.0, format!("wreck of {}", w.wreck.name))) } } } } /// The scenes that pick up a game where it was left: flying wherever you /// are, and, if you'd set down there, aboard your ship on the ground. /// Where a saved game picks up: flying, aboard your ship where it's landed, /// or aboard the wreck you were in. pub fn resume(ctx: &Ctx) -> Vec> { let mut flying = FlightScene::new(ctx); let mut scenes: Vec> = if ctx.landed { // Where you'd have been to land: over the planet, or at the dock. flying.flight.ship.pos = Vec2::ZERO; flying.walking = Some(Walking::Landed); let landed = if has_city(ctx, ctx.location) { Box::new(Aboard::resumed(ctx, ctx.location)) } else { landed(ctx, ctx.location) }; vec![Box::new(flying), landed] } else { vec![Box::new(flying)] }; if let Some(wreck) = Derelict::resumed(ctx) { scenes.push(Box::new(wreck)); } scenes } fn has_city(ctx: &Ctx, place: usize) -> bool { matches!(ctx.system.landing(place), Landing::Capital(_)) } /// Where you are once you've set down at `place`: aboard, if there's a city /// to walk out to, or else straight out at the dock or on the ground. fn landed(ctx: &Ctx, place: usize) -> Box { if has_city(ctx, place) { Box::new(Aboard::landed(ctx, place)) } else { Box::new(Port::new(ctx, place)) } } fn arrive(ctx: &Ctx) -> Flight { let locale = locale::generate(&ctx.system, ctx.location, ctx.day, ctx.departed, &ctx.stripped); let seed = seed::derive(ctx.seed, "arrival", ctx.day.to_bits() ^ ctx.location as u64); Flight::new(locale, seed) } fn days_text(days: f64) -> String { if days < 1.0 { format!("{:.0} hours", days * 24.0) } else if (days - 1.0).abs() < 0.05 { "A day".to_string() } else { format!("{days:.1} days") } } impl Scene for FlightScene { fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { if let Some(pause) = &mut self.pause { return match pause.key(key) { None => Transition::None, Some(choice) => { self.pause = None; match choice { Choice::Quit => Transition::Title, Choice::Here | Choice::Resume => Transition::None, } } }; } self.sit_down(ctx); self.catch_up(ctx); let repeat = key.kind == KeyEventKind::Repeat; if !ctx.keyboard.key_release && self.key_fallback(key.code, ctx) { return Transition::None; } if repeat { return Transition::None; } match key.code { KeyCode::Char(c) => match c.to_ascii_lowercase() { 'l' => self.land(ctx), 'b' => self.board(ctx), 'e' => self.get_up(ctx), 'm' => Transition::Push(Box::new(SystemMap::new(ctx))), 'g' => Transition::Push(Box::new(GalaxyMap::new(ctx))), 'c' => Transition::Push(Box::new(Sheet::new())), 'r' => { self.braking = true; Transition::None } _ => Transition::None, }, KeyCode::Tab => { self.next_target(); Transition::None } KeyCode::Esc => { let note = format!( "You're {}. Everything holds still while this is open.", self.flight.locale.what ); self.pause = Some(Pause::new(None, Some(note))); Transition::None } _ => Transition::None, } } fn tick(&mut self, ctx: &mut Ctx) -> Transition { if self.pause.is_some() { return Transition::None; } self.sit_down(ctx); self.catch_up(ctx); let controls = self.controls(ctx); self.flight.laser_power = trade::laser_power(ctx.lenses); for event in self.flight.step(DT, controls, ctx.hold.ore_room()) { let text = match event { Event::Collected(ore) => { *ctx.hold.ore.entry(ore.name).or_default() += 1; format!("Scooped up a ton of {}. Hold: {} t.", ore.name, ore_line(&ctx.hold)) } Event::Shattered { ore: Some(ore), .. } => { format!("The rock splits, spilling {ore}.") } Event::Shattered { ore: None, .. } => continue, Event::HoldFull => { "Your hold is full of ore. Land somewhere to sell it.".to_string() } }; self.say(text, ctx); } Transition::None } fn render(&self, frame: &mut Frame, ctx: &Ctx) { let area = frame.area(); let (map, panel) = if area.width >= PANEL_MIN_SCREEN { let [map, panel] = Layout::horizontal([Constraint::Fill(1), Constraint::Length(PANEL_WIDTH)]) .areas(area); (map, Some(panel)) } else { (area, None) }; let view = View { area: map, camera: self.flight.ship.pos }; self.draw_map(frame.buffer_mut(), &view, ctx); self.draw_messages(frame.buffer_mut(), map, ctx); match panel { Some(panel) => self.draw_panel(frame, panel, ctx), None => self.draw_status_bar(frame.buffer_mut(), map, ctx), } if let Some(pause) = &self.pause { pause.render(frame, area); } } } impl FlightScene { fn draw_map(&self, buf: &mut Buffer, view: &View, ctx: &Ctx) { let f = &self.flight; let l = &f.locale; let look = seed::derive(ctx.system.seed, "look", l.place as u64); sky::starfield(buf, view.area, view.camera, look); for (i, b) in l.backdrops.iter().enumerate() { let at = b.pos + view.camera * (1.0 - BACKDROP_PARALLAX); let surface = Surface { rgb: b.kind.rgb, banded: b.kind.banded, lights: false, ramp: sky::PLANET_RAMP, seed: seed::derive(look, "backdrop", i as u64), }; let (col, row) = view.screen(at); if !sky::sphere(buf, view.area, (col, row), b.radius, l.sun, &surface) { view.put(buf, at, '·', sky::rgb(b.kind.rgb)); } let below = row + b.radius / 2.0 + 1.0; sky::label(buf, view.area, col, below, &b.name, Color::Rgb(64, 66, 82)); } self.draw_edge(buf, view); if let Some(body) = &l.body { let surface = Surface { rgb: body.kind.rgb, banded: body.kind.banded, lights: body.settled, ramp: sky::PLANET_RAMP, seed: look, }; let (col, row) = view.screen(Vec2::ZERO); sky::sphere(buf, view.area, (col, row), body.radius, l.sun, &surface); let below = row + body.radius / 2.0 + 1.0; sky::label(buf, view.area, col, below, &body.name, DIM); } if let Some(dock) = &l.dock { sky::draw_station(buf, view, dock.culture, ctx.ticks); let (col, row) = view.screen(Vec2::ZERO); sky::label(buf, view.area, col, row + 3.0, &dock.name, DIM); } for w in &l.wrecks { let wreck = &w.wreck; let beacon = !ctx.stripped.contains(&wreck.seed) && wreck.calling(ctx.day); sky::draw_wreck(buf, view, w.pos, w.heading, beacon, ctx.ticks); if w.pos.distance(f.ship.pos) < 30.0 { let (col, row) = view.screen(w.pos); sky::label(buf, view.area, col, row + 1.0, &wreck.name, DIM); } } for rock in &l.rocks { let mut rgb = ROCK; if let Some(ore) = rock.ore { rgb = mix(rgb, ore.rgb, 0.55); } // Glows hotter as the laser works on it. let worn = 1.0 - rock.integrity / rock.size.toughness(); rgb = mix(rgb, (255, 150, 90), worn * 0.8); let surface = Surface { rgb, banded: false, lights: false, ramp: sky::ROCK_RAMP, seed: 0 }; let drawn = rock.size != Size::Small && sky::sphere( buf, view.area, view.screen(rock.pos), rock.size.radius(), l.sun, &surface, ); if !drawn { view.put(buf, rock.pos, 'o', sky::shade(rgb, 1.0)); } } for bit in &f.flotsam { let fading = FLOTSAM_LIFE - bit.age < 8.0 && ctx.ticks % 10 < 5; if !fading { view.put(buf, bit.pos, '*', sky::shade(bit.ore.rgb, 1.15)); } } for spark in &f.sparks { let k = spark.age / SPARK_LIFE; let colour = mix((255, 226, 150), (150, 50, 30), k); view.put(buf, spark.pos, if k < 0.5 { '∙' } else { '·' }, sky::rgb(colour)); } if let Some(beam) = &f.beam { let length = beam.from.distance(beam.to); let dir = (beam.to - beam.from).normalised(); let steps = (length * 2.0) as usize; for i in 0..steps { view.put(buf, beam.from + dir * (i as f64 * 0.5), '·', BEAM); } if beam.hit { let glyph = if ctx.ticks % 4 < 2 { '*' } else { '+' }; view.put(buf, beam.to, glyph, Color::Rgb(255, 236, 200)); } } if let Some((pos, r, _)) = self.target_info() { let reach = r + 1.5; view.put(buf, Vec2::new(pos.x - reach, pos.y), '[', ACCENT); view.put(buf, Vec2::new(pos.x + reach, pos.y), ']', ACCENT); } sky::draw_ship(buf, view, &f.ship, ctx.ticks); } /// Faint dots along the edge of the map, where it starts pushing back. fn draw_edge(&self, buf: &mut Buffer, view: &View) { let e = self.flight.locale.extent; let colour = Color::Rgb(56, 58, 74); let a = view.area; let (half_w, half_h) = (f64::from(a.width) / 2.0 + 1.0, f64::from(a.height) + 2.0); let (cx, cy) = (view.camera.x, view.camera.y); for x in [-e, e] { if (x - cx).abs() <= half_w { let (from, to) = ((cy - half_h).max(-e), (cy + half_h).min(e)); let mut y = (from / 2.0).ceil() * 2.0; while y <= to { view.put(buf, Vec2::new(x, y), '┊', colour); y += 2.0; } } } for y in [-e, e] { if (y - cy).abs() <= half_h { let (from, to) = ((cx - half_w).max(-e), (cx + half_w).min(e)); let mut x = from.ceil(); while x <= to { view.put(buf, Vec2::new(x, y), '┈', colour); x += 1.0; } } } } fn draw_messages(&self, buf: &mut Buffer, area: Rect, ctx: &Ctx) { let fresh: Vec<&String> = self .messages .iter() .filter(|(_, at)| { ctx.ticks.saturating_sub(*at) < MESSAGE_SECONDS * u64::from(TICKS_PER_SECOND) }) .map(|(t, _)| t) .collect(); let shown = fresh.iter().rev().take(3).rev(); let count = fresh.len().min(3) as u16; let first = area.bottom().saturating_sub(count + 1); if self.flight.past_edge() { let warning = "You're leaving the vicinity. [m] opens the map, to travel on."; text_at(buf, area, first.saturating_sub(1), warning, ACCENT); } for (row, text) in (first..).zip(shown) { text_at(buf, area, row, text, Color::Rgb(220, 220, 230)); } } fn draw_status_bar(&self, buf: &mut Buffer, area: Rect, ctx: &Ctx) { let text = format!( " {} · day {:.1} · speed {:.0} · ore {} · fuel {} ", self.flight.locale.name, ctx.day, self.flight.speed(), ore_line(&ctx.hold), ctx.hold.fuel ); let mut line = keys::line(&[("m", "map"), ("g", "stars"), ("esc", "menu")]); line.spans.insert(0, Span::raw(text).fg(ACCENT)); let bar = Rect { height: 1, ..area }; buf.set_style(bar, Style::new().bg(Color::Rgb(20, 20, 30))); buf.set_line(area.x, area.y, &line, area.width); } fn draw_panel(&self, frame: &mut Frame, area: Rect, ctx: &Ctx) { let l = &self.flight.locale; let block = Block::bordered() .title(format!(" {} ", l.name)) .border_style(Style::new().fg(DIM)) .title_style(Style::new().fg(ACCENT).bold()) .padding(Padding::horizontal(1)); let inner = block.inner(area); frame.render_widget(block, area); let hints: &[(&str, &str)] = if ctx.keyboard.key_release { &[ ("↑ w", "thrust"), ("← → a d", "turn"), ("↓ s r", "brake"), ("space f", "laser"), ("l", "land, dock"), ("b", "board"), ("e", "get up"), ("tab", "target"), ("m", "system map"), ("g", "star map"), ("c", "character"), ("esc", "menu: save and quit"), ] } else { &[ ("↑ ↓ w s", "throttle"), ("← → a d", "turn"), ("r", "brake"), ("x", "cut engines"), ("space f", "laser"), ("b", "board"), ("l", "land, dock"), ("e", "get up"), ("tab", "target"), ("c", "character"), ("m", "system map"), ("g", "star map"), ("esc", "menu: save and quit"), ] }; let hints = keys::packed(hints, inner.width); // What's left after the text, up to a square the width of the panel. let text_rows = 4 + 6 + 3 + 1 + hints.len() as u16; let radar_rows = inner.height.saturating_sub(text_rows).min(inner.width / 2).min(13); let [about, radar, status, target, _, keys] = Layout::vertical([ Constraint::Length(4), Constraint::Length(radar_rows), Constraint::Length(6), Constraint::Length(3), Constraint::Fill(1), Constraint::Length(hints.len() as u16), ]) .areas(inner); let about_lines = vec![ Line::from(l.what.clone()).fg(DIM), Line::from(format!("{} system, day {:.1}", ctx.system.name, ctx.day)).fg(DIM), ]; frame.render_widget(Paragraph::new(about_lines).wrap(Wrap { trim: true }), about); if radar.height >= 3 { self.draw_radar(frame.buffer_mut(), radar, ctx); } let f = &self.flight; let mut lines = vec![Line::from(vec![ Span::raw("speed ").fg(DIM), Span::raw(format!("{:>4.1}", f.speed())), if f.speed() <= LANDING_SPEED { Span::raw(" · can land").fg(DIM) } else { Span::raw("") }, ])]; if !ctx.keyboard.key_release { let bars: String = (1..=THROTTLE_STEPS).map(|i| if i <= self.throttle { '▮' } else { '▯' }).collect(); lines.push(Line::from(vec![Span::raw("thrust ").fg(DIM), Span::raw(bars).fg(ACCENT)])); } if self.braking { lines.push(Line::from("braking").fg(ACCENT)); } let h = &ctx.hold; lines.push(Line::from(vec![Span::raw("ore ").fg(DIM), Span::raw(ore_line(h))])); let gauge: String = (0..h.fuel_capacity).map(|k| if k < h.fuel { '◆' } else { '◇' }).collect(); lines.push(Line::from(vec![ Span::raw("fuel ").fg(DIM), Span::raw(gauge).fg(if h.fuel == 0 { Color::Rgb(214, 112, 100) } else { FUEL }), ])); lines.push(Line::from(vec![ Span::raw("hold ").fg(DIM), Span::raw(format!("{} artifacts, {} ¤", h.artifacts(), h.scrip)), ])); frame.render_widget(Paragraph::new(lines), status); let target_lines = match self.target_info() { Some((pos, _, name)) => { let off = pos - f.ship.pos; vec![ Line::from(name).fg(ACCENT), Line::from(format!("{} {:.0} away", sky::arrow(off.heading()), off.length())), ] } None if self.target == Some(Target::NearestOre) => { vec![Line::from("no ore in sight").fg(DIM)] } None => vec![keys::line(&[("tab", "pick a target")])], }; frame.render_widget(Paragraph::new(target_lines), target); frame.render_widget(Paragraph::new(hints), keys); } /// The whole local map, shrunk to fit. fn draw_radar(&self, buf: &mut Buffer, area: Rect, ctx: &Ctx) { let l = &self.flight.locale; let w = f64::from(area.width.min(area.height * 2)); let h = w / 2.0; let left = f64::from(area.x) + (f64::from(area.width) - w) / 2.0; let top = f64::from(area.y); let e = l.extent; let cell = |p: Vec2| { let col = left + (p.x + e) / (2.0 * e) * w; let row = top + (p.y + e) / (2.0 * e) * h; let inside = col >= left && col < left + w && row >= top && row < top + h; inside.then_some((col as u16, row as u16)) }; for row in 0..h as u16 { for col in 0..w as u16 { buf[(left as u16 + col, area.y + row)].set_char('·').set_fg(Color::Rgb(40, 42, 54)); } } let mut put = |p: Vec2, ch: char, fg: Color| { if let Some(pos) = cell(p) { buf[pos].set_char(ch).set_fg(fg); } }; if let Some(body) = &l.body { let step = 2.0 * e / w; let mut y = -body.radius; while y <= body.radius { let mut x = -body.radius; while x <= body.radius { if x * x + y * y <= body.radius * body.radius { put(Vec2::new(x, y), 'o', sky::shade(body.kind.rgb, 0.7)); } x += step; } y += step * 2.0; } } if l.dock.is_some() { put(Vec2::ZERO, '#', ACCENT); } for rock in &l.rocks { let fg = rock.ore.map_or(Color::Rgb(110, 104, 96), |o| sky::rgb(o.rgb)); put(rock.pos, '∙', fg); } for bit in &self.flight.flotsam { put(bit.pos, '*', sky::rgb(bit.ore.rgb)); } for wreck in &l.wrecks { let stripped = ctx.stripped.contains(&wreck.wreck.seed); put(wreck.pos, '≡', if stripped { HULL } else { Color::Rgb(255, 90, 80) }); } let ship = &self.flight.ship; put(ship.pos, sky::arrow(ship.heading), Color::White); } } /// One line of text from the left of `area`, a cell in, clipped to fit. fn text_at(buf: &mut Buffer, area: Rect, row: u16, text: &str, fg: Color) { if row < area.y || row >= area.bottom() || area.width < 3 { return; } let text: String = text.chars().take(usize::from(area.width - 2)).collect(); buf.set_string(area.x + 1, row, text, Style::new().fg(fg)); } /// "12/40 t". fn ore_line(hold: &Hold) -> String { format!("{}/{} t", hold.ore_tons(), hold.ore_capacity) } #[cfg(test)] mod tests { use ratatui::crossterm::event::KeyModifiers; use super::*; use crate::scenes::tests::test_ctx; use crate::space::system::PlaceKind; fn press(scene: &mut FlightScene, ctx: &mut Ctx, code: KeyCode) -> Transition { scene.key(KeyEvent::new(code, KeyModifiers::NONE), ctx) } fn ticks(scene: &mut FlightScene, ctx: &mut Ctx, n: u32) { for _ in 0..n { ctx.ticks += 1; scene.tick(ctx); } } #[test] fn esc_holds_everything_still_and_esc_again_carries_on() { let mut ctx = test_ctx(); let mut scene = FlightScene::new(&ctx); for _ in 0..4 { press(&mut scene, &mut ctx, KeyCode::Up); } ticks(&mut scene, &mut ctx, 5); press(&mut scene, &mut ctx, KeyCode::Esc); let held = scene.flight.ship.pos; ticks(&mut scene, &mut ctx, 30); assert_eq!(scene.flight.ship.pos, held, "nothing moves while the menu's open"); assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Esc), Transition::None)); ticks(&mut scene, &mut ctx, 5); assert_ne!(scene.flight.ship.pos, held, "and carries on after"); press(&mut scene, &mut ctx, KeyCode::Esc); assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('2')), Transition::Title)); } #[test] fn the_throttle_flies_you_forward_and_brake_stops_you() { let mut ctx = test_ctx(); let mut scene = FlightScene::new(&ctx); let start = scene.flight.ship.pos; for _ in 0..4 { press(&mut scene, &mut ctx, KeyCode::Up); } ticks(&mut scene, &mut ctx, 15); assert!(scene.flight.speed() > 5.0); assert!(scene.flight.ship.pos.distance(start) > 1.0); press(&mut scene, &mut ctx, KeyCode::Char('x')); press(&mut scene, &mut ctx, KeyCode::Char('r')); ticks(&mut scene, &mut ctx, 150); assert!(scene.flight.speed() < 0.5, "braked to {}", scene.flight.speed()); assert!(!scene.braking); } #[test] fn turn_presses_swing_you_round_an_eighth_of_a_half_turn() { let mut ctx = test_ctx(); let mut scene = FlightScene::new(&ctx); let before = scene.flight.ship.heading; press(&mut scene, &mut ctx, KeyCode::Right); ticks(&mut scene, &mut ctx, 10); let turned = crate::space::turn_between(before, scene.flight.ship.heading); assert!((turned - TURN_STEP).abs() < 1e-9, "turned {turned}"); } #[test] fn travelling_on_the_map_rebuilds_the_place() { let mut ctx = test_ctx(); let mut scene = FlightScene::new(&ctx); let belt = ctx.system.places.iter().position(|p| p.kind == PlaceKind::Belt).unwrap(); ctx.location = belt; ctx.day = 14.0; ticks(&mut scene, &mut ctx, 1); assert_eq!(scene.place, belt); assert!(scene.flight.locale.body.is_none() && scene.flight.locale.rocks.len() > 50); assert!(scene.messages.last().unwrap().0.contains("14.0 days later")); } #[test] fn jumping_brings_you_out_at_the_edge_of_another_system() { let mut ctx = test_ctx(); let mut scene = FlightScene::new(&ctx); let mut map = GalaxyMap::new(&ctx); map.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &mut ctx); map.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &mut ctx); ticks(&mut scene, &mut ctx, 1); assert_eq!((scene.star, scene.place), (ctx.star, ctx.system.edge_place())); let said = &scene.messages.last().unwrap().0; assert!(said.contains(&format!("edge of the {} system", ctx.system.name)), "{said}"); assert!(!said.contains("{where}"), "{said}"); assert!(ctx.arrival_note.is_none()); } #[test] fn you_land_only_over_the_planet_and_slowly() { let mut ctx = test_ctx(); let mut scene = FlightScene::new(&ctx); assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('l')), Transition::None)); assert!(scene.messages.last().unwrap().0.starts_with("Fly over")); scene.flight.ship.pos = Vec2::ZERO; scene.flight.ship.vel = Vec2::new(20.0, 0.0); assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('l')), Transition::None)); assert!(scene.messages.last().unwrap().0.starts_with("Too fast")); scene.flight.ship.vel = Vec2::new(2.0, 0.0); assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('l')), Transition::Push(_))); // Walking back to the pilot's seat pops the ship's inside off, and // the next tick finds you back at the controls. ticks(&mut scene, &mut ctx, 1); assert!(scene.messages.last().unwrap().0.starts_with("You lift off from")); } #[test] fn without_a_city_you_land_straight_at_the_dock() { let mut ctx = test_ctx(); let station = ctx.system.places.iter().position(|p| matches!(p.kind, PlaceKind::Station(_))).unwrap(); ctx.location = station; let mut scene = FlightScene::new(&ctx); let Transition::Push(mut docked) = scene.touch_down(&mut ctx) else { panic!("docks") }; let l = KeyEvent::from(KeyCode::Char('l')); assert!(matches!(docked.key(l, &mut ctx), Transition::Pop), "l undocks, from the dock"); ticks(&mut scene, &mut ctx, 1); assert!(scene.messages.last().unwrap().0.starts_with("You undock from")); assert!(!ctx.landed); // A game saved there picks up there too. ctx.landed = true; let mut stack = resume(&ctx); assert!(matches!(stack[1].key(l, &mut ctx), Transition::Pop)); } #[test] fn you_pick_up_where_you_were_landed_or_flying() { let mut ctx = test_ctx(); assert_eq!(resume(&ctx).len(), 1, "flying"); let mut scene = FlightScene::new(&ctx); scene.flight.ship.pos = Vec2::ZERO; press(&mut scene, &mut ctx, KeyCode::Char('l')); assert!(ctx.landed); let mut stack = resume(&ctx); assert_eq!(stack.len(), 2, "aboard, landed"); ticks(&mut scene, &mut ctx, 1); assert!(!ctx.landed, "taking off"); // Taking off from a game picked up landed is from where you landed. ctx.landed = true; stack[0].tick(&mut ctx); assert!(!ctx.landed); } #[test] fn you_get_up_from_the_controls_only_when_slow() { let mut ctx = test_ctx(); let mut scene = FlightScene::new(&ctx); scene.flight.ship.vel = Vec2::new(20.0, 0.0); assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('e')), Transition::None)); assert!(scene.messages.last().unwrap().0.starts_with("Too fast")); scene.flight.ship.vel = Vec2::new(2.0, 0.0); assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('e')), Transition::Push(_))); assert_eq!(scene.flight.speed(), 0.0, "the ship holds station"); ticks(&mut scene, &mut ctx, 1); assert!(scene.messages.last().unwrap().0.contains("take the controls")); } #[test] fn scooped_ore_goes_in_the_hold() { let mut ctx = test_ctx(); let mut scene = FlightScene::new(&ctx); let ore = &locale::ORES[2]; let pos = scene.flight.ship.pos; scene.flight.flotsam.push(crate::space::flight::Flotsam { pos, vel: Vec2::ZERO, ore, age: 0.0, }); ticks(&mut scene, &mut ctx, 1); assert_eq!(ctx.hold.ore.get("iridium"), Some(&1)); } #[test] fn boarding_a_wreck_strips_it() { let mut ctx = test_ctx(); let wreck = ctx.system.wrecks_on(ctx.day)[0].clone(); ctx.location = wreck.place; let mut scene = FlightScene::new(&ctx); let w = scene.flight.locale.wrecks.iter().find(|w| w.wreck == wreck).unwrap(); scene.flight.ship.pos = w.pos; assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('b')), Transition::Push(_))); assert!(ctx.stripped.contains(&wreck.seed)); assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('b')), Transition::None)); assert!(scene.messages.last().unwrap().0.contains("already stripped")); // The hulk stays while you're here, and is gone when you come back. let again = FlightScene::new(&ctx); assert!(again.flight.locale.wrecks.iter().all(|w| w.wreck != wreck)); } #[test] fn wrecks_you_left_alone_drift_off_in_time() { let mut ctx = test_ctx(); let wreck = ctx.system.wrecks_on(ctx.day)[0].clone(); ctx.location = wreck.place; let here = |ctx: &Ctx| { let scene = FlightScene::new(ctx); scene.flight.locale.wrecks.iter().find(|w| w.wreck == wreck).map(|w| w.pos) }; let first = here(&ctx).expect("calling today"); ctx.day = (ctx.day + wreck.leaves) / 2.0; assert_eq!(here(&ctx), Some(first), "still there, in the same spot"); ctx.day = wreck.leaves + 0.5; assert_eq!(here(&ctx), None, "its beacon failed, and it's gone"); } #[test] fn a_wreck_you_set_out_for_waits_for_you() { let mut ctx = test_ctx(); let wreck = ctx.system.wrecks_on(ctx.day)[0].clone(); let elsewhere = (0..ctx.system.places.len()).find(|&p| p != wreck.place).unwrap(); ctx.location = elsewhere; let here = |ctx: &Ctx| { let scene = FlightScene::new(ctx); scene.flight.locale.wrecks.iter().any(|w| w.wreck == wreck) }; // A trip so slow that its beacon fails on the way. ctx.travel(wreck.place, wreck.leaves - ctx.day + 1.0); assert!(!wreck.calling(ctx.day)); assert!(here(&ctx), "you came by its last fix"); assert!(ctx.wrecks().contains(&wreck), "and the map knows it's here"); ctx.day += 3.0; assert!(here(&ctx), "it stays while you do"); ctx.travel(elsewhere, 1.0); assert!(!ctx.wrecks().contains(&wreck)); ctx.travel(wreck.place, 1.0); assert!(!here(&ctx), "come back another time and it's gone"); } }