summaryrefslogtreecommitdiff
path: root/src/scenes/flight.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/flight.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/flight.rs')
-rw-r--r--src/scenes/flight.rs1163
1 files changed, 1163 insertions, 0 deletions
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<Target>,
+ messages: Vec<(String, u64)>,
+ walking: Option<Walking>,
+ /// The menu `esc` opens. Everything holds still while it's open.
+ pause: Option<Pause>,
+}
+
+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<Box<dyn Scene>> {
+ let mut flying = FlightScene::new(ctx);
+ let mut scenes: Vec<Box<dyn Scene>> = 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<dyn Scene> {
+ 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");
+ }
+}