summaryrefslogtreecommitdiff
path: root/src/scenes/pause.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/pause.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/pause.rs')
-rw-r--r--src/scenes/pause.rs160
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));
+ }
+}