//! Aboard your own ship, on foot. //! //! Landing leaves you in the cockpit. To go ashore you walk aft through the //! ship to the airlock in the stern, and to take off again you walk back //! forward and sit in the pilot's seat. Out in space you can get up from //! the controls whenever you're flying slowly: the ship holds station while //! you walk about, and if a wreck is alongside, the airlock boards it. //! //! The ship is drawn by hand below, in the shape of the dart you fly: the //! cockpit in the nose, an engine room in each swept-back wing, and the //! airlock in the notch between them. The hold has a bin for every five //! tons of ore you can carry, more as cargo racks are fitted, and shows //! what's in them. The shelf in the crew quarters shows the artifacts in //! your lockers, and the med cabinet the medpatches. //! //! `i` opens the inventory, with the lockers beside what you carry. What //! you put down on the deck stays where you left it, for good. `<` walks //! you forward to the pilot's seat and `>` aft to the airlock, stopping //! beside them. use ratatui::Frame; use ratatui::buffer::Buffer; use ratatui::crossterm::event::{KeyCode, KeyEvent}; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Modifier, Style, Stylize}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Padding, Paragraph, Wrap}; use super::character::Sheet; use super::derelict::{Derelict, camera}; use super::inventory::{self, Beside, Inventory, Reply, View}; use super::keys::{self, ACCENT, DIM}; use super::pause::{Choice, Pause}; use super::port::Port; use super::sky::{mix, rgb}; use super::town::TownScene; use super::{Scene, Transition}; use crate::app::{Ctx, Hold}; use crate::dungeon::Pos; use crate::items::{self, Item, Stack}; use crate::space::locale::ORES; use crate::space::system::{Landing, PlaceKind, Rgb, Wreck}; use crate::trade; const PANEL_WIDTH: u16 = 32; const LOG_HEIGHT: u16 = 6; /// The most steps one run takes. const RUN_LIMIT: usize = 60; /// Tons of ore to a bin in the hold. const BIN_TONS: u32 = 5; /// The middle row. With the nose to the east, rows above it are the port /// side and rows below it starboard. const MIDSHIPS: i32 = 9; /// Your ship, nose to the east, one character to a cell: /// /// `#` hull, `x` bulkhead, `.` deck, `:` engine-room grating, `/` doorway, /// `A` the airlock, `w` windscreen, `P` the pilot's seat, `c` crew seats, /// `n` `N` `r` the flight, nav and comms consoles, `E` the drives, /// `k` workbenches, `O` ore bins, `1` to `3` the bins each set of cargo /// racks adds, `b` bunks, `B` the med-bay couch, `m` the med cabinet, /// `*` the shelf for artifacts, `l` lockers, `C` crates, `t` the galley /// table, `h` chairs, `s` the galley, `f` a fern. #[rustfmt::skip] const LAYOUT: [&str; 19] = [ "##############", "#:EE:::::::::#####", "#:EE:::::k:::::::#####", "#::::::::::::::::::::#", "####xxx/xxxxxxxxxxxxx####################", " #..............x.bb.bb.bb.**lxmm...BB#####", " #.OOOO.11.22.33x.............x.......x...#####", " #..............x.............x.......x.......#####", " #..............xxxxxxx/xxxxxxxxxx/xxxx.......cNNN#www", " A............../...................../..........Pnnnw", " #..............xxxxxxx/xxxxxxxxxx/xxxx.......crrr#www", " #..............x....hh.......x.......x.......#####", " #.OOOO.11.22.33x...htth......x......Cx...#####", " #..............xsss.........fxlllll.C#####", "####xxx/xxxxxxxxxxxxx####################", "#::::::::::::::::::::#", "#:EE:::::k:::::::#####", "#:EE:::::::::#####", "##############", ]; const HEIGHT: i32 = LAYOUT.len() as i32; const WIDTH: i32 = { let mut width = 0; let mut i = 0; while i < LAYOUT.len() { if LAYOUT[i].len() > width { width = LAYOUT[i].len(); } i += 1; } width as i32 }; /// A room: its name, and its top left and bottom right corners. struct Room(&'static str, (i32, i32), (i32, i32)); /// The first room that holds a cell names it, so doorways go with the /// corridor or the hold. const ROOMS: [Room; 9] = [ Room("the cockpit", (41, 6), (54, 12)), Room("the corridor", (19, 8), (40, 10)), Room("the crew quarters", (19, 5), (31, 7)), Room("the galley", (19, 11), (31, 13)), Room("the med-bay", (33, 5), (39, 7)), Room("the stores", (33, 11), (39, 13)), Room("the hold", (3, 4), (18, 14)), Room("the port engine room", (1, 1), (20, 3)), Room("the starboard engine room", (1, 15), (20, 17)), ]; /// Box-drawing glyphs for walls, by which neighbours they join: 1 north, /// 2 east, 4 south, 8 west. The hull is drawn in double lines. const DOUBLE: [char; 16] = ['■', '║', '═', '╚', '║', '║', '╔', '╠', '═', '╝', '═', '╩', '╗', '╣', '╦', '╬']; const SINGLE: [char; 16] = ['■', '│', '─', '└', '│', '│', '┌', '├', '─', '┘', '─', '┴', '┐', '┤', '┬', '┼']; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Cell { Void, Hull, Wall, Deck, Grating, Door, Airlock, Window, PilotSeat, /// An ore bin: one the ship came with (set 0), or one of the `n`th set /// of cargo racks, which is bare deck until they're fitted. Bin(u8), Fixture(Fixture), } impl Cell { fn walkable(self) -> bool { matches!(self, Cell::Deck | Cell::Grating | Cell::Door) } /// Whether a wall beside it joins up with it. fn joins(self) -> bool { matches!(self, Cell::Hull | Cell::Wall | Cell::Airlock | Cell::Window) } } /// Things you bump into to look at. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Fixture { FlightConsole, NavConsole, Comms, CrewSeat, Drive, Workbench, Bunk, Couch, MedCabinet, Shelf, Locker, Crate, Table, Chair, Galley, Fern, } fn parse(ch: u8) -> Option { use Fixture as F; let fixture = match ch { b' ' => return Some(Cell::Void), b'#' => return Some(Cell::Hull), b'x' => return Some(Cell::Wall), b'.' => return Some(Cell::Deck), b':' => return Some(Cell::Grating), b'/' => return Some(Cell::Door), b'A' => return Some(Cell::Airlock), b'w' => return Some(Cell::Window), b'P' => return Some(Cell::PilotSeat), b'O' => return Some(Cell::Bin(0)), b'1'..=b'3' => return Some(Cell::Bin(ch - b'0')), b'n' => F::FlightConsole, b'N' => F::NavConsole, b'r' => F::Comms, b'c' => F::CrewSeat, b'E' => F::Drive, b'k' => F::Workbench, b'b' => F::Bunk, b'B' => F::Couch, b'm' => F::MedCabinet, b'*' => F::Shelf, b'l' => F::Locker, b'C' => F::Crate, b't' => F::Table, b'h' => F::Chair, b's' => F::Galley, b'f' => F::Fern, _ => return None, }; Some(Cell::Fixture(fixture)) } /// The cell at `p` as drawn, before any cargo racks are fitted. Off the /// map is `Void`. fn layout(p: Pos) -> Cell { let row = usize::try_from(p.y).ok().and_then(|y| LAYOUT.get(y)); let ch = row.and_then(|row| usize::try_from(p.x).ok().and_then(|x| row.as_bytes().get(x))); ch.and_then(|&ch| parse(ch)).unwrap_or(Cell::Void) } /// Every cell drawn as `ch`, row by row. fn positions(ch: u8) -> impl Iterator { LAYOUT.iter().enumerate().flat_map(move |(y, row)| { row.bytes() .enumerate() .filter(move |&(_, c)| c == ch) .map(move |(x, _)| Pos::new(x as i32, y as i32)) }) } fn seat() -> Pos { positions(b'P').next().expect("the ship has a pilot's seat") } fn room_at(p: Pos) -> Option<&'static str> { ROOMS .iter() .find(|Room(_, (x0, y0), (x1, y1))| { (*x0..=*x1).contains(&p.x) && (*y0..=*y1).contains(&p.y) }) .map(|room| room.0) } /// Every bin, with its set, in the order they fill: the ship's own, from /// aft forward, then each set of racks in turn. fn bins() -> Vec<(u8, Pos)> { let mut bins: Vec<(u8, Pos)> = (0..HEIGHT) .flat_map(|y| (0..WIDTH).map(move |x| Pos::new(x, y))) .filter_map(|p| match layout(p) { Cell::Bin(set) => Some((set, p)), _ => None, }) .collect(); bins.sort_by_key(|&(set, p)| (set, p.x, p.y)); bins } /// How many sets of cargo racks are fitted. fn racks(hold: &Hold) -> u8 { let extra = hold.ore_capacity.saturating_sub(Hold::BASE_ORE_CAPACITY); u8::try_from(extra / trade::RACK_TONS).unwrap_or(u8::MAX) } /// The ore in the first `count` bins: tons in each, and the colour of the /// ore that fills most of it. fn bin_contents(hold: &Hold, count: usize) -> Vec<(u32, Option)> { let mut bins = vec![(0, None::<(Rgb, u32)>); count]; let mut i = 0; for (name, &tons) in &hold.ore { let colour = ORES.iter().find(|o| o.name == *name).map_or((150, 138, 124), |o| o.rgb); let mut left = tons; while left > 0 && i < count { let (filled, most) = &mut bins[i]; let pour = left.min(BIN_TONS - *filled); *filled += pour; if most.is_none_or(|(_, t)| pour > t) { *most = Some((colour, pour)); } left -= pour; if *filled == BIN_TONS { i += 1; } } } bins.into_iter().map(|(tons, most)| (tons, most.map(|(colour, _)| colour))).collect() } /// Where the ship is while you walk about it. #[derive(Clone, Debug, PartialEq)] pub enum Berth { /// Set down at a place: a city's landing field, a station's dock, or /// open ground where no one lives. Landed(usize), /// Holding station out in space, with a wreck alongside if there's one. Adrift(Option), } /// Where you went through the airlock to. While you're there, its scene is /// the one on top of this. #[derive(Clone, Copy, Debug, PartialEq)] enum Outside { City, Port, Wreck, } pub struct Aboard { berth: Berth, /// Where the ship is, for the panel: "landed on Qeth", "holding station /// in orbit around Qeth, a desert world". whereabouts: String, player: Pos, log: Vec, /// How many things have been said, ever: the log itself stops growing. said: usize, outside: Option, bins: Vec<(u8, Pos)>, shelf: Vec, /// Open over the ship when you're looking through what you carry, with /// the lockers or the deck beside it. inventory: Option<(Inventory, Beside)>, /// The menu `esc` opens. pause: Option, } impl Aboard { /// Just landed or docked at `place`: you get up from the pilot's seat. pub fn landed(ctx: &Ctx, place: usize) -> Aboard { let name = &ctx.system.places[place].name; let (whereabouts, arrival) = match ctx.system.landing(place) { Landing::Capital(_) => ( format!("landed on {name}"), format!("You set down on {name}, on the landing field outside its capital."), ), Landing::Market(_) => (format!("docked at {name}"), format!("You dock at {name}.")), _ => { (format!("landed on {name}"), format!("You set down on {name}. No one lives here.")) } }; let mut scene = Aboard::new(Berth::Landed(place), whereabouts); scene.say(arrival); scene.say("You unbuckle and get up. The airlock is aft, through the hold.".into()); scene } /// Back aboard, landed at `place`, in a game you've come back to. pub fn resumed(ctx: &Ctx, place: usize) -> Aboard { let mut scene = Aboard::landed(ctx, place); scene.log.clear(); let ship = ctx.character.ship_name(); let name = &ctx.system.places[place].name; let berth = match ctx.system.landing(place) { Landing::Market(_) => format!("docked at {name}"), _ => format!("landed on {name}"), }; scene.say(format!("You're aboard {ship}, {berth}, on day {:.0}.", ctx.day.floor())); scene.say("The airlock is aft, through the hold: [>] walks you there.".into()); scene } /// Up from the controls out in space, where the ship is `what` ("in /// orbit around Qeth, a desert world"), with `wreck` alongside if /// there's one. pub fn adrift(what: &str, wreck: Option) -> Aboard { let alongside = wreck.as_ref().map(|w| capitalise(&w.name)); let mut scene = Aboard::new(Berth::Adrift(wreck), format!("holding station {what}")); scene.say("You set the ship to hold station, unbuckle and get up.".into()); if let Some(name) = alongside { scene.say(format!("{name} drifts alongside. The airlock is aft, through the hold.")); } scene } fn new(berth: Berth, whereabouts: String) -> Aboard { Aboard { berth, whereabouts, player: seat().offset(-1, 0), log: Vec::new(), said: 0, outside: None, bins: bins(), shelf: positions(b'*').collect(), inventory: None, pause: None, } } fn say(&mut self, text: String) { self.log.push(text); self.said += 1; if self.log.len() > 50 { self.log.remove(0); } } /// The cell at `p`, with racks that aren't fitted left out. fn cell(&self, p: Pos, hold: &Hold) -> Cell { match layout(p) { Cell::Bin(set) if set > racks(hold) => Cell::Deck, cell => cell, } } /// Back in through the airlock, if you were out. fn come_back(&mut self) { let Some(outside) = self.outside.take() else { return }; let text = match outside { Outside::City => "You climb the ramp and seal the airlock behind you.", Outside::Port => "You come back aboard and seal the airlock.", Outside::Wreck => "The airlock cycles, and you're back aboard your own ship.", }; self.say(text.into()); } fn step(&mut self, dx: i32, dy: i32, ctx: &mut Ctx) -> Transition { let to = self.player.offset(dx, dy); match self.cell(to, &ctx.hold) { cell if cell.walkable() => { self.player = to; if let Some(text) = inventory::underfoot(&inventory::lying(&ctx.hold.deck, to)) { self.say(text); } } Cell::PilotSeat => return Transition::Pop, Cell::Airlock => return self.airlock(ctx), Cell::Window => { let text = self.view_out(ctx); self.say(text); } Cell::Bin(_) => self.say(hold_text(&ctx.hold)), // The weapons locker opens the lockers. Cell::Fixture(Fixture::Locker) if to.y < MIDSHIPS => { self.say("You open the lockers.".into()); self.inventory = Some((Inventory::new(), Beside::Lockers)); } Cell::Fixture(fixture) => { let text = self.about(fixture, to, ctx); self.say(text); } _ => {} } Transition::None } /// `g`: picks up what's on the deck where you stand, or lists it if /// there's more than one thing. fn pick_up(&mut self, ctx: &mut Ctx) { let here = inventory::lying(&ctx.hold.deck, self.player); match &here[..] { [] => self.say("There's nothing here to pick up.".into()), [stack] => { let command = inventory::Command::Fetch(0, stack.count); let text = self.carry_out(command, Beside::Floor("on the deck"), ctx); self.say(text); } _ => self.inventory = Some((Inventory::beside(), Beside::Floor("on the deck"))), } } /// Does what the inventory asks, with the lockers or the deck beside you. fn carry_out(&mut self, command: inventory::Command, beside: Beside, ctx: &mut Ctx) -> String { let h = &mut ctx.hold; let mut floor = inventory::gather(&mut h.deck, self.player); let lockers = (beside == Beside::Lockers).then_some(&mut h.lockers); let text = inventory::carry_out(command, &mut h.gear, lockers, &mut floor, Some(beside)); inventory::scatter(&mut h.deck, self.player, floor); text } /// A key while the inventory's open. fn inventory_key(&mut self, key: KeyEvent, ctx: &mut Ctx) { let Some((inv, beside)) = &mut self.inventory else { return }; let beside = *beside; let floor = inventory::lying(&ctx.hold.deck, self.player); let view = view(ctx, beside, &floor); match inv.key(key, &view) { Reply::Stay => {} Reply::Close => self.inventory = None, Reply::Sort(sort) => ctx.inventory_sort = sort, Reply::Do(command) => { let text = self.carry_out(command, beside, ctx); if let Some((inv, _)) = &mut self.inventory { inv.note = Some(text.clone()); } self.say(text); } } } /// Steps until something changes: a wall, a doorway into another room, /// something said. fn run(&mut self, dx: i32, dy: i32, ctx: &mut Ctx) -> Transition { for _ in 0..RUN_LIMIT { let (before, room, said) = (self.player, room_at(self.player), self.said); let transition = self.step(dx, dy, ctx); if !matches!(transition, Transition::None) { return transition; } if self.player == before || self.said != said || room_at(self.player) != room { break; } } Transition::None } /// Out through the airlock: into the city, the station or the wreck /// alongside, if there's anywhere to go. fn airlock(&mut self, ctx: &mut Ctx) -> Transition { match self.berth { Berth::Landed(place) => { if matches!(ctx.system.landing(place), Landing::Capital(_)) { self.outside = Some(Outside::City); Transition::Push(Box::new(TownScene::new(ctx, place))) } else { self.outside = Some(Outside::Port); Transition::Push(Box::new(Port::new(ctx, place))) } } Berth::Adrift(None) => { self.say( "There's nothing outside the airlock but vacuum. Bring the ship alongside \ a wreck to board it." .into(), ); Transition::None } Berth::Adrift(Some(ref wreck)) => { let wreck = wreck.clone(); if ctx.stripped.contains(&wreck.seed) { let text = format!("You've already stripped {}. Nothing's left aboard.", wreck.name); self.say(text); Transition::None } else { let seed = wreck.seed; ctx.stripped.insert(seed); self.outside = Some(Outside::Wreck); Transition::Push(Box::new(Derelict::board(seed, ctx))) } } } } /// `<` or `>`: walks to stand beside the pilot's seat or the airlock, /// by the shortest way, so one more step takes off or goes out. fn walk_to(&mut self, goal: Cell, ctx: &Ctx) { let (what, way) = match goal { Cell::PilotSeat => ("the pilot's seat ╥", "forward"), _ => ("the airlock Θ", "aft"), }; let beside = |p: Pos| { [(0, -1), (1, 0), (0, 1), (-1, 0)] .into_iter() .any(|(dx, dy)| layout(p.offset(dx, dy)) == goal) }; if beside(self.player) { self.say(format!("You're beside {what}.")); return; } // Breadth first, the way you walk: a step in any of eight directions. let mut seen = std::collections::HashSet::from([self.player]); let mut queue = std::collections::VecDeque::from([self.player]); while let Some(p) = queue.pop_front() { if beside(p) { self.player = p; self.say(format!("You walk {way} to {what}.")); if let Some(text) = inventory::underfoot(&inventory::lying(&ctx.hold.deck, p)) { self.say(text); } return; } for dy in -1..=1 { for dx in -1..=1 { let next = p.offset(dx, dy); if self.cell(next, &ctx.hold).walkable() && seen.insert(next) { queue.push_back(next); } } } } self.say(format!("There's no way through to {what}.")); } /// Esc: which way to go to leave. fn way_out(&self) -> String { match self.berth { Berth::Landed(_) => "To take off, sit in the pilot's seat ╥, forward in the cockpit \ ([<] walks you there). To go out, it's the airlock Θ, aft ([>])." .into(), Berth::Adrift(_) => "To fly on, sit in the pilot's seat ╥, forward in the cockpit \ ([<] walks you there)." .into(), } } fn view_out(&self, ctx: &Ctx) -> String { match self.berth { Berth::Landed(place) => { let name = &ctx.system.places[place].name; match ctx.system.landing(place) { Landing::Capital(_) => format!( "Through the windscreen: the landing field, and beyond it the walls of \ {name}'s capital." ), Landing::Market(_) => { format!( "Through the windscreen: the docking bay of {name}, and dockhands at work." ) } _ => format!("Through the windscreen: {name}, empty as far as you can see."), } } Berth::Adrift(_) => { format!("Through the windscreen: the stars. The ship is {}.", self.whereabouts) } } } /// What you see when you bump `fixture`, at `at`. fn about(&self, fixture: Fixture, at: Pos, ctx: &Ctx) -> String { let h = &ctx.hold; let port = at.y < MIDSHIPS; match fixture { Fixture::FlightConsole => "The flight console. Sit in the pilot's seat to fly.".into(), Fixture::NavConsole => { format!("The nav console: the {} system, day {:.1}.", ctx.system.name, ctx.day) } Fixture::Comms => "The comms board. Nothing on the open channels but static.".into(), Fixture::CrewSeat if port => "The co-pilot's seat, with a jacket slung over it.".into(), Fixture::CrewSeat => "The engineer's seat, worn smooth.".into(), Fixture::Drive => { let side = if port { "port" } else { "starboard" }; match self.berth { Berth::Landed(_) => format!("The {side} drive, cold and ticking as it cools."), Berth::Adrift(_) => { format!("The {side} drive, idling, holding the ship on station.") } } } Fixture::Workbench => { "A workbench, with tools clipped to the bulkhead above it.".into() } Fixture::Bunk => "A bunk, made up tight.".into(), Fixture::Couch => "The med-bay couch, its straps buckled down.".into(), Fixture::MedCabinet => match stored(h, |i| matches!(i, Item::Medpatch)).count() { 0 => "The med cabinet is empty. Doctors in the cities sell medpatches.".into(), 1 => "The med cabinet holds a medpatch. Pack it (i) to take it aboard a wreck." .into(), n => format!( "The med cabinet holds {n} medpatches. Pack them (i) to take them aboard \ a wreck." ), }, Fixture::Shelf => { let names: Vec = stored(h, |i| matches!(i, Item::Artifact(_))).map(|i| i.name()).collect(); if names.is_empty() { "An empty shelf, for the strange things you find aboard wrecks.".into() } else { format!("On the shelf: {}.", listed(&names)) } } Fixture::Locker => "Lockers of spare parts, coolant and air filters.".into(), Fixture::Crate => "A crate of ration bricks, lashed to the deck.".into(), Fixture::Table => "The galley table, bolted to the deck.".into(), Fixture::Chair => "A chair, bolted down in case the gravity goes.".into(), Fixture::Galley => "The galley: a hotplate, a kettle and a water still.".into(), Fixture::Fern => "A potted fern. Against the odds, it's thriving.".into(), } } } /// One of each thing in the lockers that's `kind`, counting stacks out. fn stored(h: &Hold, kind: impl Fn(&Item) -> bool) -> impl Iterator { h.lockers .iter() .filter(move |s| kind(&s.item)) .flat_map(|s| std::iter::repeat_n(&s.item, s.count as usize)) } /// What the inventory's shown: what you carry, beside the lockers or the /// deck where you stand (`floor`). fn view<'a>(ctx: &'a Ctx, beside: Beside, floor: &'a [Stack]) -> View<'a> { let pile = match beside { Beside::Lockers => &ctx.hold.lockers[..], Beside::Floor(_) | Beside::Rack => floor, }; View { gear: &ctx.hold.gear, beside: (beside == Beside::Lockers || !pile.is_empty()).then_some((beside, pile)), known: &ctx.bestiary, hurt: false, shop: None, sort: ctx.inventory_sort, } } /// What the ore bins hold. fn hold_text(h: &Hold) -> String { let tons = h.ore_tons(); if tons == 0 { return format!( "The ore bins are empty. They take {} t; the mining laser fills them.", h.ore_capacity ); } let kinds: Vec = h.ore.iter().filter(|&(_, &t)| t > 0).map(|(name, t)| format!("{t} t of {name}")).collect(); format!("The ore bins hold {}: {tons} of {} t.", listed(&kinds), h.ore_capacity) } /// "a, b and c". fn listed(items: &[String]) -> String { match items { [] => String::new(), [one] => one.clone(), [rest @ .., last] => format!("{} and {last}", rest.join(", ")), } } fn capitalise(s: &str) -> String { let mut chars = s.chars(); chars.next().map_or(String::new(), |c| c.to_uppercase().chain(chars).collect()) } impl Scene for Aboard { fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { self.come_back(); if let Some(pause) = &mut self.pause { let Some(choice) = pause.key(key) else { return Transition::None }; self.pause = None; return match choice { Choice::Quit => Transition::Title, Choice::Here | Choice::Resume => Transition::None, }; } if self.inventory.is_some() { self.inventory_key(key, ctx); return Transition::None; } match key.code { KeyCode::Char('c' | 'C') => return Transition::Push(Box::new(Sheet::new())), KeyCode::Char('i') => { self.inventory = Some((Inventory::new(), Beside::Lockers)); return Transition::None; } KeyCode::Char('g' | ',') => { self.pick_up(ctx); return Transition::None; } KeyCode::Char('<') => { self.walk_to(Cell::PilotSeat, ctx); return Transition::None; } KeyCode::Char('>') => { self.walk_to(Cell::Airlock, ctx); return Transition::None; } KeyCode::Esc => { self.pause = Some(Pause::new(None, Some(self.way_out()))); return Transition::None; } _ => {} } match keys::movement(key) { Some((dx, dy, true)) => self.run(dx, dy, ctx), Some((dx, dy, false)) => self.step(dx, dy, ctx), None => Transition::None, } } fn tick(&mut self, _ctx: &mut Ctx) -> Transition { self.come_back(); Transition::None } fn render(&self, frame: &mut Frame, ctx: &Ctx) { let [main, panel] = Layout::horizontal([Constraint::Fill(1), Constraint::Length(PANEL_WIDTH)]) .areas(frame.area()); let [map, log] = Layout::vertical([Constraint::Fill(1), Constraint::Length(LOG_HEIGHT)]).areas(main); self.draw_map(frame.buffer_mut(), map, ctx); self.draw_log(frame, log); self.draw_panel(frame, panel, ctx); if let Some((inv, beside)) = &self.inventory { let floor = inventory::lying(&ctx.hold.deck, self.player); inv.render(frame, frame.area(), &view(ctx, *beside, &floor)); } if let Some(pause) = &self.pause { pause.render(frame, frame.area()); } } } /// A wall's glyph, joined up with the walls around it. fn wall_glyph(p: Pos, glyphs: &[char; 16]) -> char { let joins = |dx, dy| usize::from(layout(p.offset(dx, dy)).joins()); glyphs[joins(0, -1) | joins(1, 0) << 1 | joins(0, 1) << 2 | joins(-1, 0) << 3] } /// A console's lights, a few of them off at any moment. fn lights(p: Pos, ticks: u64, on: Rgb) -> Rgb { let k = (ticks / 10) as i64 + i64::from(p.x) * 7 + i64::from(p.y) * 3; if k.rem_euclid(5) == 0 { mix(on, (0, 0, 0), 0.55) } else { on } } fn bin_look((tons, ore): (u32, Option)) -> (char, Rgb) { match ore { Some(colour) if tons >= BIN_TONS => ('■', colour), Some(colour) if tons > 0 => ('▪', colour), _ => ('□', (96, 100, 116)), } } /// What's out of the windscreen when you're down: the ground, or the dock. fn outside_rgb(ctx: &Ctx, place: usize) -> Rgb { let s = &ctx.system; match s.places[place].kind { PlaceKind::Planet(p) => s.planets[p].kind.rgb, PlaceKind::Moon(p, m) => s.planets[p].moons[m].kind.rgb, PlaceKind::Station(_) | PlaceKind::Belt => (150, 160, 180), } } impl Aboard { fn draw_map(&self, buf: &mut Buffer, area: Rect, ctx: &Ctx) { let left = camera(self.player.x, WIDTH, i32::from(area.width)); let top = camera(self.player.y, HEIGHT, i32::from(area.height)); let racks = racks(&ctx.hold); let bins: Vec = self.bins.iter().filter(|&&(set, _)| set <= racks).map(|&(_, p)| p).collect(); let contents = bin_contents(&ctx.hold, bins.len()); for sy in 0..area.height { for sx in 0..area.width { let p = Pos::new(left + i32::from(sx), top + i32::from(sy)); let (glyph, colour, bold) = if p == self.player { ('@', (255, 255, 255), true) } else { self.look(p, ctx, &bins, &contents) }; let cell = &mut buf[(area.x + sx, area.y + sy)]; cell.set_char(glyph).set_fg(rgb(colour)); if bold { cell.modifier.insert(Modifier::BOLD); } } } } /// How the cell at `p` looks, leaving out you. fn look( &self, p: Pos, ctx: &Ctx, bins: &[Pos], contents: &[(u32, Option)], ) -> (char, Rgb, bool) { let ticks = ctx.ticks; // What's put down on the deck, the last thing on top. if let Some((_, stack)) = ctx.hold.deck.iter().rev().find(|(at, _)| *at == p) { let (glyph, colour) = inventory::look(&stack.item); return (glyph, colour, false); } let (glyph, colour) = match self.cell(p, &ctx.hold) { Cell::Void => (' ', (0, 0, 0)), Cell::Hull => (wall_glyph(p, &DOUBLE), (122, 134, 160)), Cell::Wall => (wall_glyph(p, &SINGLE), (150, 150, 162)), // Where racks would be bolted, if you bought them. Cell::Deck if matches!(layout(p), Cell::Bin(_)) => ('·', (84, 88, 104)), Cell::Deck => ('.', (100, 104, 120)), Cell::Grating => (':', (84, 88, 104)), Cell::Door => ('\'', (216, 170, 86)), Cell::Airlock => return ('Θ', (90, 222, 200), true), Cell::PilotSeat => return ('╥', (240, 200, 110), true), Cell::Window => match self.berth { Berth::Landed(place) => ('▒', mix(outside_rgb(ctx, place), (170, 200, 230), 0.3)), Berth::Adrift(_) => { let twinkle = (ticks / 20) as i64 + i64::from(p.x) * 7 + i64::from(p.y) * 11; if twinkle.rem_euclid(13) == 0 { ('·', (220, 226, 255)) } else { ('░', (44, 52, 96)) } } }, Cell::Bin(_) => { let i = bins.iter().position(|&b| b == p); bin_look(i.and_then(|i| contents.get(i).copied()).unwrap_or((0, None))) } Cell::Fixture(fixture) => self.fixture_look(fixture, p, ctx), }; (glyph, colour, false) } fn fixture_look(&self, fixture: Fixture, p: Pos, ctx: &Ctx) -> (char, Rgb) { let ticks = ctx.ticks; let h = &ctx.hold; match fixture { Fixture::FlightConsole => ('▤', lights(p, ticks, (110, 220, 150))), Fixture::NavConsole => ('▤', lights(p, ticks, (110, 180, 240))), Fixture::Comms => ('▤', lights(p, ticks, (230, 170, 90))), Fixture::CrewSeat => ('╥', (150, 140, 124)), Fixture::Drive => match self.berth { Berth::Landed(_) => ('¤', (96, 104, 130)), Berth::Adrift(_) => { let k = (ticks as f64 / 6.0 + f64::from(p.x + p.y)).sin() * 0.5 + 0.5; ('¤', mix((80, 110, 210), (190, 220, 255), k)) } }, Fixture::Workbench => ('╦', (150, 156, 172)), Fixture::Bunk => ('▬', (120, 140, 190)), Fixture::Couch => ('▬', (210, 214, 226)), Fixture::MedCabinet if stored(h, |i| matches!(i, Item::Medpatch)).next().is_some() => { ('+', (240, 96, 96)) } Fixture::MedCabinet => ('+', (120, 64, 64)), Fixture::Shelf => { let i = self.shelf.iter().position(|&s| s == p).unwrap_or(0); if i < stored(h, |i| matches!(i, Item::Artifact(_))).count() { ('*', (255, 214, 92)) } else { ('≡', (130, 112, 84)) } } Fixture::Locker => ('▪', (130, 140, 160)), Fixture::Crate => ('■', (170, 140, 96)), Fixture::Table => ('╤', (150, 156, 172)), Fixture::Chair => ('╥', (120, 124, 140)), Fixture::Galley => ('◘', (170, 176, 190)), Fixture::Fern => ('♣', (90, 170, 80)), } } fn draw_log(&self, frame: &mut Frame, area: Rect) { let shown = usize::from(area.height.saturating_sub(1)); let first = self.log.len().saturating_sub(shown); let lines: Vec = self.log[first..] .iter() .enumerate() .map(|(i, text)| { let age = self.log.len() - first - 1 - i; let line = Line::from(text.as_str()); match age { 0 => line.white(), 1 | 2 => line.gray(), _ => line.dark_gray(), } }) .collect(); let block = Block::new().borders(Borders::TOP).border_style(Style::new().dark_gray()); frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }).block(block), area); } fn draw_panel(&self, frame: &mut Frame, area: Rect, ctx: &Ctx) { let block = Block::bordered() .border_style(Style::new().dark_gray()) .title(Line::from(format!(" {} ", ctx.character.ship_name())).bold().fg(ACCENT)) .padding(Padding::horizontal(1)); let inner = block.inner(area); frame.render_widget(block, area); let (seat, airlock) = match self.berth { Berth::Landed(place) => ( "pilot's seat ╥: take off", match ctx.system.landing(place) { Landing::Market(_) => "airlock Θ: to the station", _ => "airlock Θ: go out", }, ), Berth::Adrift(Some(_)) => ("pilot's seat ╥: fly on", "airlock Θ: board the wreck"), Berth::Adrift(None) => ("pilot's seat ╥: fly on", "airlock Θ: only vacuum"), }; let hints = keys::packed( &[ ("arrows hjkl yubn", "walk"), ("shift", "run"), ("g", "pick up"), ("i", "inventory"), ("c", "character"), ("bump", "look at things"), ("<", seat), (">", airlock), ("esc", "menu: save and quit"), ], inner.width, ); let [info, help] = Layout::vertical([Constraint::Fill(1), Constraint::Length(hints.len() as u16 + 1)]) .areas(inner); let h = &ctx.hold; let label = |text: &'static str| Span::raw(text).fg(DIM); let room = room_at(self.player).unwrap_or("aboard"); let mut lines = vec![ Line::from("your ship").fg(DIM), Line::from(self.whereabouts.clone()).fg(DIM), Line::default(), Line::from(capitalise(room)).fg(ACCENT), ]; if room != "the cockpit" { lines.push(Line::from(" the cockpit is forward").fg(DIM)); } let somewhere_to_go = !matches!(self.berth, Berth::Adrift(None)); if room != "the hold" && somewhere_to_go { lines.push(Line::from(" the airlock is aft").fg(DIM)); } lines.extend([ Line::default(), Line::from(vec![label("money "), Span::raw(format!("{} ¤", h.scrip))]), Line::from(vec![ label("ore "), Span::raw(format!("{}/{} t", h.ore_tons(), h.ore_capacity)), ]), Line::from(vec![ label("fuel "), Span::raw(format!("{}/{} jumps", h.fuel, h.fuel_capacity)), ]), Line::from(vec![label("lenses "), Span::raw(ctx.lenses.to_string())]), Line::from(vec![ label("carrying "), Span::raw(format!( "{}/{} kg", items::kg(h.gear.carried()), items::kg(h.gear.capacity) )), ]), Line::from(vec![ label("lockers "), Span::raw(format!( "{}/{} kg", items::kg(h.locker_mass()), items::kg(items::LOCKER_CAPACITY) )), ]), Line::from(vec![label("medpatches "), Span::raw(h.medpatches().to_string())]), ]); frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), info); // A line between what's here and the keys. let [_, help] = Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(help); frame.render_widget(Paragraph::new(hints), help); } } #[cfg(test)] mod tests { use ratatui::crossterm::event::KeyModifiers; use std::collections::{HashMap, VecDeque}; use super::*; use crate::names; use crate::scenes::tests::test_ctx; use crate::seed; fn press(scene: &mut Aboard, ctx: &mut Ctx, code: KeyCode) -> Transition { scene.key(KeyEvent::new(code, KeyModifiers::NONE), ctx) } fn key_towards(dx: i32, dy: i32) -> KeyCode { match (dx, dy) { (-1, 0) => KeyCode::Left, (1, 0) => KeyCode::Right, (0, -1) => KeyCode::Up, (0, 1) => KeyCode::Down, (-1, -1) => KeyCode::Home, (1, -1) => KeyCode::PageUp, (-1, 1) => KeyCode::End, _ => KeyCode::PageDown, } } /// The steps from `from` to every cell you can walk to, as a way back: /// each cell's previous one. fn paths(scene: &Aboard, hold: &Hold, from: Pos) -> HashMap { let mut came_from = HashMap::from([(from, from)]); let mut queue = VecDeque::from([from]); while let Some(p) = queue.pop_front() { for n in p.neighbours() { if scene.cell(n, hold).walkable() && !came_from.contains_key(&n) { came_from.insert(n, p); queue.push_back(n); } } } came_from } /// Walks, one key at a time, to `to`, and then a step on towards /// `bump`, returning what that last step did. fn walk(scene: &mut Aboard, ctx: &mut Ctx, to: Pos, bump: Pos) -> Transition { let came_from = paths(scene, &ctx.hold, scene.player); let mut route = vec![to]; while let Some(&before) = came_from.get(route.last().unwrap()) && before != scene.player { route.push(before); } assert!(came_from.contains_key(&to), "can't walk to {to:?}"); for &p in route.iter().rev() { let (dx, dy) = (p.x - scene.player.x, p.y - scene.player.y); assert!(matches!(press(scene, ctx, key_towards(dx, dy)), Transition::None)); assert_eq!(scene.player, p); } press(scene, ctx, key_towards(bump.x - to.x, bump.y - to.y)) } fn airlock() -> Pos { positions(b'A').next().unwrap() } #[test] fn the_layout_is_whole() { for row in LAYOUT { for ch in row.bytes() { assert!(parse(ch).is_some(), "{:?} isn't a cell", ch as char); } } assert_eq!(positions(b'P').count(), 1); assert_eq!(positions(b'A').count(), 1); // Everything walkable is in a room, and you can get to all of it, // and to everything you can bump, from the pilot's seat. let mut ctx = test_ctx(); ctx.hold.ore_capacity = Hold::BASE_ORE_CAPACITY; let scene = Aboard::adrift("nowhere", None); let reached = paths(&scene, &ctx.hold, scene.player); for y in 0..HEIGHT { for x in 0..WIDTH { let p = Pos::new(x, y); let cell = scene.cell(p, &ctx.hold); if cell.walkable() { assert!(reached.contains_key(&p), "can't reach {p:?}"); assert!(room_at(p).is_some(), "{p:?} isn't in a room"); } // The flight console is in front of the pilot's seat, out of // reach unless you're sitting in it. let bumpable = matches!(cell, Cell::Fixture(_) | Cell::Bin(_) | Cell::Airlock) && cell != Cell::Fixture(Fixture::FlightConsole); if bumpable || cell == Cell::PilotSeat { assert!(p.neighbours().any(|n| reached.contains_key(&n)), "can't reach {p:?}"); } } } } #[test] fn there_is_a_bin_for_every_five_tons() { let mut hold = Hold::default(); let all = bins(); for _ in 0..4 { let racks = racks(&hold); let count = all.iter().filter(|&&(set, _)| set <= racks).count() as u32; assert_eq!(count * BIN_TONS, hold.ore_capacity); hold.ore_capacity += trade::RACK_TONS; } hold.ore_capacity = Hold::BASE_ORE_CAPACITY; hold.ore.insert("nickel", 12); hold.ore.insert("iridium", 3); let contents = bin_contents(&hold, 8); let tons: Vec = contents.iter().map(|c| c.0).collect(); assert_eq!(tons, [5, 5, 5, 0, 0, 0, 0, 0]); let colour = |name: &str| ORES.iter().find(|o| o.name == name).unwrap().rgb; // Ores go in by name: the first bin has 3 t of iridium in it, and // 2 t of nickel on top. assert_eq!(contents[0].1, Some(colour("iridium"))); assert_eq!(contents[2].1, Some(colour("nickel"))); assert_eq!(contents[3].1, None); } #[test] fn you_walk_aft_to_go_ashore_and_forward_to_take_off() { let mut ctx = test_ctx(); let home = ctx.system.home_place(); let mut scene = Aboard::landed(&ctx, home); assert_eq!(room_at(scene.player), Some("the cockpit")); let door = airlock().offset(1, 0); let out = walk(&mut scene, &mut ctx, door, airlock()); assert!(matches!(out, Transition::Push(_)), "the airlock opens on the city"); assert_eq!(scene.outside, Some(Outside::City)); // Back from the city, you're just inside the airlock. scene.tick(&mut ctx); assert!(scene.log.last().unwrap().contains("seal the airlock")); assert_eq!(scene.player, door); let stand = seat().offset(-1, 0); assert!(matches!(walk(&mut scene, &mut ctx, stand, seat()), Transition::Pop)); } #[test] fn angle_brackets_walk_you_to_the_airlock_and_the_seat() { let mut ctx = test_ctx(); let home = ctx.system.home_place(); let mut scene = Aboard::landed(&ctx, home); press(&mut scene, &mut ctx, KeyCode::Char('>')); assert_eq!(layout(scene.player.offset(-1, 0)), Cell::Airlock, "beside it, not through"); assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('h')), Transition::Push(_))); press(&mut scene, &mut ctx, KeyCode::Char('<')); assert_eq!(layout(scene.player.offset(1, 0)), Cell::PilotSeat); let said = scene.said; press(&mut scene, &mut ctx, KeyCode::Char('<')); assert_eq!(scene.said, said + 1, "you're told you're there already"); assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Char('l')), Transition::Pop)); } #[test] fn running_stops_at_doorways() { let mut ctx = test_ctx(); let mut scene = Aboard::adrift("nowhere", None); let run = |scene: &mut Aboard, ctx: &mut Ctx| { scene.key(KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT), ctx) }; run(&mut scene, &mut ctx); assert_eq!(scene.player, Pos::new(40, MIDSHIPS), "out of the cockpit"); run(&mut scene, &mut ctx); assert_eq!(scene.player, Pos::new(18, MIDSHIPS), "down the corridor"); assert!(matches!(run(&mut scene, &mut ctx), Transition::None)); assert_eq!(scene.player, airlock().offset(1, 0), "across the hold, to vacuum"); assert!(scene.log.last().unwrap().contains("vacuum")); } #[test] fn the_airlock_boards_a_wreck_alongside_once() { let mut ctx = test_ctx(); let wreck = ctx.system.wrecks_on(ctx.day)[0].clone(); let mut scene = Aboard::adrift("nowhere", Some(wreck.clone())); scene.player = airlock().offset(1, 0); assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Left), Transition::Push(_))); assert!(ctx.stripped.contains(&wreck.seed)); assert!(matches!(press(&mut scene, &mut ctx, KeyCode::Left), Transition::None)); assert!(scene.log.last().unwrap().contains("already stripped")); } #[test] fn the_hold_and_the_shelf_show_what_you_carry() { let mut ctx = test_ctx(); let mut scene = Aboard::landed(&ctx, ctx.system.home_place()); let shelf = positions(b'*').next().unwrap(); scene.player = shelf.offset(0, 1); press(&mut scene, &mut ctx, KeyCode::Up); assert!(scene.log.last().unwrap().starts_with("An empty shelf")); let artifact = names::artifact(&mut seed::rng(4)); let name = artifact.name().to_string(); ctx.hold.lockers.push(Stack::one(Item::Artifact(artifact))); press(&mut scene, &mut ctx, KeyCode::Up); assert_eq!(scene.log.last().unwrap(), &format!("On the shelf: {name}.")); ctx.hold.ore.insert("nickel", 7); let (_, bin) = bins()[0]; scene.player = bin.offset(0, 1); press(&mut scene, &mut ctx, KeyCode::Up); assert_eq!(scene.log.last().unwrap(), "The ore bins hold 7 t of nickel: 7 of 40 t."); // Racks you haven't bought are bare deck you can walk over. let (_, rack) = *bins().iter().find(|&&(set, _)| set == 1).unwrap(); assert!(scene.cell(rack, &ctx.hold).walkable()); ctx.hold.ore_capacity += trade::RACK_TONS; assert_eq!(scene.cell(rack, &ctx.hold), Cell::Bin(1)); } #[test] fn what_you_put_down_on_the_deck_stays_there() { let mut ctx = test_ctx(); let mut scene = Aboard::landed(&ctx, ctx.system.home_place()); let wire = Item::Salvage(items::Salvage { title: "coil of wire".into(), value: 9, mass: 2000 }); ctx.hold.gear.add(Stack { item: wire.clone(), count: 3 }); let start = scene.player; press(&mut scene, &mut ctx, KeyCode::Char('i')); assert!(scene.inventory.is_some()); let shown = screen(&scene, &ctx); assert!(shown.contains("YOU ·") && shown.contains("LOCKERS"), "{shown}"); for c in "/wire".chars() { press(&mut scene, &mut ctx, KeyCode::Char(c)); } press(&mut scene, &mut ctx, KeyCode::Enter); for c in "2d".chars() { press(&mut scene, &mut ctx, KeyCode::Char(c)); } press(&mut scene, &mut ctx, KeyCode::Char('m')); press(&mut scene, &mut ctx, KeyCode::Esc); assert!(scene.inventory.is_some(), "esc clears what you were finding first"); press(&mut scene, &mut ctx, KeyCode::Esc); assert!(scene.inventory.is_none()); assert_eq!( inventory::lying(&ctx.hold.deck, start), [Stack { item: wire.clone(), count: 2 }] ); assert_eq!(ctx.hold.lockers, [Stack::one(wire.clone())]); assert!(ctx.hold.gear.pack.iter().all(|s| s.item != wire)); // Walk off and it's on the deck; walk back on and you're told. press(&mut scene, &mut ctx, KeyCode::Char('h')); assert_ne!(scene.player, start); let (glyph, _, _) = scene.look(start, &ctx, &[], &[]); assert_eq!(glyph, '('); press(&mut scene, &mut ctx, KeyCode::Char('l')); assert!(scene.log.last().unwrap().contains("2 coils of wire here")); press(&mut scene, &mut ctx, KeyCode::Char('g')); assert!(ctx.hold.deck.is_empty()); assert!(ctx.hold.gear.pack.contains(&Stack { item: wire, count: 2 })); } #[test] fn the_weapons_locker_opens_the_lockers() { let mut ctx = test_ctx(); let mut scene = Aboard::landed(&ctx, ctx.system.home_place()); let locker = positions(b'l').find(|p| p.y < MIDSHIPS).unwrap(); let from = locker.neighbours().find(|&n| scene.cell(n, &ctx.hold).walkable()).unwrap(); scene.player = from; press(&mut scene, &mut ctx, key_towards(locker.x - from.x, locker.y - from.y)); assert!(matches!(scene.inventory, Some((_, Beside::Lockers)))); } fn screen(scene: &Aboard, ctx: &Ctx) -> String { let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 40)).unwrap(); terminal.draw(|frame| scene.render(frame, ctx)).unwrap(); terminal.backend().buffer().content().iter().map(|cell| cell.symbol()).collect() } }