diff options
| author | skylar <me@casper> | 2026-11-11 15:24:37 -0800 |
|---|---|---|
| committer | skylar <me@casper> | 2026-11-11 15:24:37 -0800 |
| commit | 9ccf951473e21a8b90c25161156fa09a9b227a7b (patch) | |
| tree | e7bd9858a4cdbc5ae50069db8ca0cf868e436dec /src/scenes/pause.rs | |
| parent | d36c54d996f3c14ae811312dbbe621e12a39edde (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/pause.rs')
| -rw-r--r-- | src/scenes/pause.rs | 160 |
1 files changed, 160 insertions, 0 deletions
diff --git a/src/scenes/pause.rs b/src/scenes/pause.rs new file mode 100644 index 0000000..41cff43 --- /dev/null +++ b/src/scenes/pause.rs @@ -0,0 +1,160 @@ +//! The menu `esc` opens over a game screen: keep playing, do what `esc` +//! used to do there (abandon ship, back to your ship), or save and quit to +//! the title. It opens on keep playing, and `esc` again closes it: quitting +//! is always something you choose. +//! +//! Like the inventory, it's a panel a scene holds and draws over itself, +//! not a scene of its own: the scene beneath stays in view, and stops while +//! the menu's open. + +use ratatui::Frame; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind}; +use ratatui::layout::{Constraint, Flex, Layout, Rect}; +use ratatui::style::{Modifier, Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Clear, Padding, Paragraph, Wrap}; + +use super::keys::{self, ACCENT, DIM}; + +/// What you chose. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Choice { + /// Save, and go back to the title screen. + Quit, + /// Whatever `esc` does here besides: abandon ship, leave the building. + Here, + /// Close the menu and carry on. + Resume, +} + +pub struct Pause { + choice: usize, + /// What else you can do here, if there's anything: "Abandon ship". + here: Option<&'static str>, + /// A word about where you are, or which way to go. + note: Option<String>, +} + +impl Pause { + pub fn new(here: Option<&'static str>, note: Option<String>) -> Pause { + Pause { choice: 0, here, note } + } + + fn choices(&self) -> Vec<(Choice, &'static str)> { + let mut choices = vec![(Choice::Resume, "Keep playing")]; + if let Some(here) = self.here { + choices.push((Choice::Here, here)); + } + choices.push((Choice::Quit, "Save and quit to the title")); + choices + } + + /// Takes a key. Says what you chose, if you chose anything. A key held + /// down doesn't count again: holding `esc` mustn't flicker the menu. + pub fn key(&mut self, key: KeyEvent) -> Option<Choice> { + if key.kind == KeyEventKind::Repeat { + return None; + } + let choices = self.choices(); + let last = choices.len() - 1; + match key.code { + KeyCode::Esc | KeyCode::Char('q') => Some(Choice::Resume), + KeyCode::Down | KeyCode::Char('j') | KeyCode::Tab => { + self.choice = (self.choice + 1).min(last); + None + } + KeyCode::Up | KeyCode::Char('k') | KeyCode::BackTab => { + self.choice = self.choice.saturating_sub(1); + None + } + KeyCode::Char('g') | KeyCode::Home => { + self.choice = 0; + None + } + KeyCode::Char('G') | KeyCode::End => { + self.choice = last; + None + } + KeyCode::Enter | KeyCode::Char(' ' | 'l') => Some(choices[self.choice.min(last)].0), + KeyCode::Char(c @ '1'..='9') => { + choices.get(c as usize - '1' as usize).map(|&(choice, _)| choice) + } + _ => None, + } + } + + /// Draws the menu in the middle of `area`. + pub fn render(&self, frame: &mut Frame, area: Rect) { + let mut lines = Vec::new(); + if let Some(note) = &self.note { + lines.push(Line::from(note.clone()).fg(DIM)); + lines.push(Line::default()); + } + for (i, (_, what)) in self.choices().into_iter().enumerate() { + let chosen = i == self.choice; + let marker = if chosen { "› " } else { " " }; + let line = Line::from(vec![ + Span::raw(format!("{} ", i + 1)).fg(ACCENT), + Span::raw(marker).fg(ACCENT), + Span::raw(what), + ]); + lines.push(if chosen { + line.patch_style(Style::new().add_modifier(Modifier::REVERSED)) + } else { + line + }); + } + lines.push(Line::default()); + lines.push(keys::line(&[("↑↓ enter", "choose"), ("esc", "keep playing")])); + + let [popup] = Layout::horizontal([Constraint::Max(48)]).flex(Flex::Center).areas(area); + let width = usize::from(popup.width.saturating_sub(4)).max(1); + let rows: usize = lines.iter().map(|l| l.width().div_ceil(width).max(1)).sum(); + let height = u16::try_from(rows + 2).unwrap_or(u16::MAX); + let [popup] = + Layout::vertical([Constraint::Length(height)]).flex(Flex::Center).areas(popup); + let block = Block::bordered() + .border_style(Style::new().fg(ACCENT)) + .title(Line::from(" PAUSED ").fg(ACCENT).bold()) + .padding(Padding::horizontal(1)); + frame.render_widget(Clear, popup); + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }).block(block), popup); + } +} + +#[cfg(test)] +mod tests { + use ratatui::crossterm::event::KeyModifiers; + + use super::*; + + fn press(pause: &mut Pause, code: KeyCode) -> Option<Choice> { + pause.key(KeyEvent::new(code, KeyModifiers::NONE)) + } + + #[test] + fn esc_again_closes_the_menu_and_holding_it_does_nothing() { + let mut pause = Pause::new(Some("Abandon ship"), None); + let held = KeyEvent::new_with_kind(KeyCode::Esc, KeyModifiers::NONE, KeyEventKind::Repeat); + assert_eq!(pause.key(held), None); + assert_eq!(press(&mut pause, KeyCode::Esc), Some(Choice::Resume)); + assert_eq!(press(&mut pause, KeyCode::Char('j')), None); + assert_eq!(press(&mut pause, KeyCode::Esc), Some(Choice::Resume), "wherever the cursor is"); + } + + #[test] + fn you_can_pick_what_else_esc_does_or_carry_on() { + let mut pause = Pause::new(Some("Abandon ship"), None); + assert_eq!(press(&mut pause, KeyCode::Enter), Some(Choice::Resume), "it opens on that"); + assert_eq!(press(&mut pause, KeyCode::Char('j')), None); + assert_eq!(press(&mut pause, KeyCode::Enter), Some(Choice::Here)); + assert_eq!(press(&mut pause, KeyCode::Char('G')), None); + assert_eq!(press(&mut pause, KeyCode::Enter), Some(Choice::Quit)); + assert_eq!(press(&mut pause, KeyCode::Char('1')), Some(Choice::Resume)); + assert_eq!(press(&mut pause, KeyCode::Char('3')), Some(Choice::Quit)); + assert_eq!(press(&mut pause, KeyCode::Char('q')), Some(Choice::Resume)); + assert_eq!(press(&mut pause, KeyCode::Char('x')), None, "anything else does nothing"); + let mut pause = Pause::new(None, None); + assert_eq!(press(&mut pause, KeyCode::Char('2')), Some(Choice::Quit)); + } +} |