From 9ccf951473e21a8b90c25161156fa09a9b227a7b Mon Sep 17 00:00:00 2001 From: skylar Date: Wed, 11 Nov 2026 15:24:37 -0800 Subject: Orrery 0.1.0: the galaxy, flight, cities, inventory and saves Grows the derelict crawler into the whole game, and gets it ready to publish. - A galaxy of star systems joined by lanes, split between four peoples' territories, with a star map and a system map. Systems are modelled with Kepler orbits, moons inside their Hill spheres, belts past the frost line and stations at Lagrange points. - Flight around one place at a time: arcade flight with a throttle fallback where held keys can't be detected, mining rocks for ore, landing, docking at stations, and boarding wrecks that come and go by the calendar. - Your ship to walk about, and capital cities to land at: generated plans with walls, quarters and streets, building interiors, shops, taverns, scholars and armourers. - Fighting with a weapon and a gun ready, six damage types and how each monster takes them, and armour that guards, wears and can be mended. - Everything you own has a weight: a vim-keyed inventory, a pack and the ship's lockers, and trade in ore, salvage, arms and fuel. - Characters with a people and a ship of their own, saved as versioned JSON on every scene change and on quit, with a character sheet, a saves list and a pause menu wherever you are. - `orrery weapons`, `armour`, `galaxy`, `system` and `town` print the generators' output without the TUI. - Release: README install steps for macOS and Linux, a Makefile whose install also works with macOS's install, an MIT licence, and Cargo metadata with rust-version 1.88. Co-Authored-By: Claude Opus 5.5 --- src/scenes/title.rs | 178 ++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 139 insertions(+), 39 deletions(-) (limited to 'src/scenes/title.rs') diff --git a/src/scenes/title.rs b/src/scenes/title.rs index 7c75888..898452e 100644 --- a/src/scenes/title.rs +++ b/src/scenes/title.rs @@ -1,4 +1,6 @@ -//! Title screen: a turning orrery over a drifting starfield. +//! Title screen: a turning orrery over a drifting starfield, and the way in: +//! carry on as the character you last played, make a new one, or load +//! another. //! //! Every universe seed gets its own orrery, and everything here moves as a //! function of `ctx.ticks`, so it doubles as a check that the tick loop runs. @@ -16,10 +18,15 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::widgets::canvas::{Canvas, Points}; +use super::character::{Create, Sheet}; use super::derelict::Derelict; +use super::flight; use super::forge::Forge; +use super::keys::ACCENT; +use super::saves::Saves; use super::{Scene, Transition}; use crate::app::{Ctx, TICKS_PER_SECOND}; +use crate::save; use crate::seed; const LOGO: [&str; 4] = [ @@ -64,8 +71,13 @@ struct Planet { } pub struct Title { + /// Whose orrery this is. Loading or making a character can change + /// universe under it. + universe: u64, stars: Vec, planets: Vec, + /// Whether there are saved characters to load. + saved: bool, } impl Title { @@ -92,7 +104,8 @@ impl Title { } }) .collect(); - Title { stars, planets } + let saved = ctx.saves.as_deref().is_some_and(|dir| !save::list(dir).is_empty()); + Title { universe: ctx.seed, stars, planets, saved } } fn draw_stars(&self, buf: &mut Buffer, area: Rect, seconds: f64) { @@ -143,16 +156,43 @@ impl Title { } } +/// The menu: key, what it does. +fn menu(ctx: &Ctx, saved: bool) -> Vec<(&'static str, String)> { + let mut items = Vec::new(); + if ctx.slot.is_some() { + let c = &ctx.character; + items.push(("enter", format!("carry on as {}, of {}", c.name, c.ship_name()))); + items.push(("c", "your character".into())); + } + items.push(("n", "a new character".into())); + if saved { + items.push(("l", "load a character".into())); + } + if ctx.slot.is_some() { + items.push(("d", "board a derelict, straight away".into())); + } + items.push(("g", "the name forge".into())); + items.push(("q", "leave".into())); + items +} + impl Scene for Title { fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { + let playing = ctx.slot.is_some(); match key.code { + KeyCode::Enter if playing => Transition::Open(flight::resume(ctx)), + KeyCode::Enter => Transition::Push(Box::new(Create::new(ctx))), KeyCode::Char(c) => match c.to_ascii_lowercase() { - 'd' => { + 'f' if playing => Transition::Open(flight::resume(ctx)), + 'c' if playing => Transition::Push(Box::new(Sheet::new())), + 'n' => Transition::Push(Box::new(Create::new(ctx))), + 'l' if self.saved => Transition::Push(Box::new(Saves::new(ctx))), + 'd' if playing => { ctx.boarded += 1; let ship = seed::derive(ctx.seed, "derelict", ctx.boarded); - Transition::Push(Box::new(Derelict::new(ship))) + Transition::Push(Box::new(Derelict::board(ship, ctx))) } - 'n' => Transition::Push(Box::new(Forge::new(ctx))), + 'g' => Transition::Push(Box::new(Forge::new(ctx))), 'q' => Transition::Quit, _ => Transition::None, }, @@ -161,17 +201,26 @@ impl Scene for Title { } } + fn on_top(&mut self, ctx: &Ctx) { + if ctx.seed != self.universe { + *self = Title::new(ctx); + } else { + self.saved = ctx.saves.as_deref().is_some_and(|dir| !save::list(dir).is_empty()); + } + } + fn render(&self, frame: &mut Frame, ctx: &Ctx) { let seconds = ctx.ticks as f64 / f64::from(TICKS_PER_SECOND); let area = frame.area(); self.draw_stars(frame.buffer_mut(), area, seconds); + let items = menu(ctx, self.saved); let [sky, logo, _, menu, hold, footer] = Layout::vertical([ Constraint::Fill(1), Constraint::Length(LOGO.len() as u16), Constraint::Length(1), - Constraint::Length(4), - Constraint::Length(1), + Constraint::Length(items.len() as u16), + Constraint::Length(2), Constraint::Length(1), ]) .areas(area); @@ -181,44 +230,95 @@ impl Scene for Title { let logo_lines: Vec = LOGO.iter().map(|l| Line::from(*l).bold()).collect(); frame.render_widget(Paragraph::new(logo_lines).centered().fg(Color::Yellow), logo); - let item = |key: &'static str, label: &'static str, built: bool| { - let style = if built { Style::new() } else { Style::new().fg(Color::DarkGray) }; - let mut spans = vec![Span::styled(key, style.bold()), Span::styled(label, style)]; - if !built { - spans.push(Span::styled(" (not yet built)", style.italic())); - } - Line::from(spans) - }; - let [menu] = Layout::horizontal([Constraint::Length(46)]).flex(Flex::Center).areas(menu); - frame.render_widget( - Paragraph::new(vec![ - item("[d] ", "board a derelict", true), - item("[n] ", "the name forge", true), - item("[f] ", "fly a star system", false), - item("[q] ", "leave", true), - ]), - menu, - ); - - let h = &ctx.hold; - if !h.artifacts.is_empty() || h.scrip > 0 { - let plural = if h.artifacts.len() == 1 { "" } else { "s" }; + // Keys in a column, in the colour key hints are everywhere. + let keys = items.iter().map(|(k, _)| k.chars().count()).max().unwrap_or(0) + 2; + let width = items.iter().map(|(_, l)| keys + l.chars().count()).max(); + let [menu] = Layout::horizontal([Constraint::Length(width.unwrap_or(0) as u16)]) + .flex(Flex::Center) + .areas(menu); + let lines: Vec = items + .into_iter() + .map(|(key, label)| { + Line::from(vec![ + Span::raw(format!("{key: format!("aboard {}", c.name), + None => format!("at {}", ctx.system.places[ctx.location].name), + }; let text = format!( - "your hold: {} artifact{plural}, {} ¤ of salvage", - h.artifacts.len(), + "day {:.1} {place} in {} · {} artifact{plural}, {} t of ore, {} ¤", + ctx.day, + ctx.system.name, + h.artifacts(), + h.ore_tons(), h.scrip ); + let [_, hold] = Layout::vertical([Constraint::Length(1); 2]).areas(hold); frame.render_widget(Paragraph::new(text).centered().fg(Color::Yellow), hold); } - let keys = if ctx.keyboard.key_release { - "held keys detectable (kitty keyboard protocol)" - } else { - "held keys not detectable: flight will need a throttle" + let footer_text = match &ctx.save_error { + Some(problem) => Paragraph::new(problem.as_str()).centered().light_red(), + None => { + let keys = if ctx.keyboard.key_release { + "held keys detectable (kitty keyboard protocol)" + } else { + "held keys not detectable: flight will need a throttle" + }; + Paragraph::new(format!("universe {} · {keys}", ctx.seed)).centered().dark_gray() + } + }; + frame.render_widget(footer_text, footer); + } +} + +#[cfg(test)] +mod tests { + use ratatui::crossterm::event::KeyModifiers; + + use super::*; + use crate::scenes::tests::test_ctx; + + fn press(title: &mut Title, ctx: &mut Ctx, code: KeyCode) -> Transition { + title.key(KeyEvent::new(code, KeyModifiers::NONE), ctx) + } + + #[test] + fn without_a_character_you_make_one() { + let mut ctx = test_ctx(); + let mut title = Title::new(&ctx); + let keys: Vec<&str> = menu(&ctx, false).iter().map(|&(key, _)| key).collect(); + assert_eq!(keys, ["n", "g", "q"]); + assert!(matches!(press(&mut title, &mut ctx, KeyCode::Enter), Transition::Push(_))); + assert!(matches!(press(&mut title, &mut ctx, KeyCode::Char('d')), Transition::None)); + assert!(matches!(press(&mut title, &mut ctx, KeyCode::Char('l')), Transition::None)); + } + + #[test] + fn with_one_you_carry_on_where_they_were() { + let mut ctx = test_ctx(); + ctx.slot = Some("someone.json".into()); + let mut title = Title::new(&ctx); + let c = &ctx.character; + let carry_on = format!("carry on as {}, of {}", c.name, c.ship_name()); + assert_eq!(menu(&ctx, true)[0], ("enter", carry_on)); + let Transition::Open(scenes) = press(&mut title, &mut ctx, KeyCode::Enter) else { + panic!("enter carries on"); }; - frame.render_widget( - Paragraph::new(format!("universe {} · {keys}", ctx.seed)).centered().dark_gray(), - footer, - ); + assert_eq!(scenes.len(), 1, "flying, as they weren't landed"); + + // Whoever comes back to the title may be from another universe. + ctx.begin(99, ctx.character.clone()); + title.on_top(&ctx); + assert_eq!(title.universe, 99); } } -- cgit v1.2.3