From 9ccf951473e21a8b90c25161156fa09a9b227a7b Mon Sep 17 00:00:00 2001 From: skylar Date: Wed, 11 Nov 2026 15:24:37 -0800 Subject: 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 --- src/scenes/flight.rs | 1163 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1163 insertions(+) create mode 100644 src/scenes/flight.rs (limited to 'src/scenes/flight.rs') diff --git a/src/scenes/flight.rs b/src/scenes/flight.rs new file mode 100644 index 0000000..04c56d0 --- /dev/null +++ b/src/scenes/flight.rs @@ -0,0 +1,1163 @@ +//! 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"); + } +} -- cgit v1.2.3