From d36c54d996f3c14ae811312dbbe621e12a39edde Mon Sep 17 00:00:00 2001 From: skylar Date: Tue, 10 Nov 2026 06:50:08 -0800 Subject: Orrery: game loop, name generator and derelict dungeons An ASCII sci-fi-fantasy game for the terminal, built on ratatui. - Game loop with a fixed 30 Hz tick and a stack of scenes; the kitty keyboard protocol is turned on where the terminal supports it, so later real-time scenes can tell when a key is held. - Stable hierarchical seeds: the whole universe derives from one u64, with tests pinning the exact outputs so old seeds keep working. - Name generator with four cultures, each with its own syllables and templates, plus unidentified/true names for artifacts. - Title screen with an animated orrery, and a name forge for browsing generated names. - Derelict ships to board: ship-shaped maps with an engine room, spine corridor, side rooms and bridge; symmetric shadowcasting sight; monsters that wake on sight and hunt; loot carried out through the airlock goes into the hold. - `orrery names` and `orrery ship` print the generators' output without the TUI, for tuning. Co-Authored-By: Claude Opus 5.5 --- src/app.rs | 110 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 src/app.rs (limited to 'src/app.rs') diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..e3fe72a --- /dev/null +++ b/src/app.rs @@ -0,0 +1,110 @@ +//! The game loop and the scene stack. + +use std::io; +use std::time::{Duration, Instant}; + +use ratatui::DefaultTerminal; +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; + +use crate::names::Artifact; +use crate::scenes::{Scene, Transition, title::Title}; +use crate::term::{self, KeyboardCaps}; + +/// Simulation rate. Real-time scenes (flight) move by this many steps a +/// second; turn-based scenes (dungeons) can ignore ticks and react to keys. +pub const TICKS_PER_SECOND: u32 = 30; +const TICK: Duration = Duration::from_nanos(1_000_000_000 / TICKS_PER_SECOND as u64); +/// After a stall (suspended with ^Z, a slow terminal), run at most this many +/// missed ticks, then carry on from now instead of fast-forwarding. +const MAX_CATCH_UP: u32 = 5; + +/// State every scene can see. +pub struct Ctx { + /// The universe seed. Everything procedural derives from it (see `seed`). + pub seed: u64, + /// Ticks since the game started. + pub ticks: u64, + pub keyboard: KeyboardCaps, + /// How many derelicts you've boarded. Each boarding is a different ship, + /// `derive(seed, "derelict", n)`. + pub boarded: u64, + pub hold: Hold, +} + +impl Ctx { + pub fn new(seed: u64, keyboard: KeyboardCaps) -> Ctx { + Ctx { seed, ticks: 0, keyboard, boarded: 0, hold: Hold::default() } + } +} + +/// What you've brought back from expeditions. +#[derive(Default)] +pub struct Hold { + pub artifacts: Vec, + /// Salvage, counted by what it'll sell for. + pub scrip: u32, +} + +pub fn run(seed: u64) -> io::Result<()> { + let (mut terminal, keyboard) = term::init()?; + let ctx = Ctx::new(seed, keyboard); + let result = run_loop(&mut terminal, ctx); + term::restore(keyboard); + result +} + +fn run_loop(terminal: &mut DefaultTerminal, mut ctx: Ctx) -> io::Result<()> { + let mut stack: Vec> = vec![Box::new(Title::new(&ctx))]; + let mut next_tick = Instant::now() + TICK; + + while let Some(top) = stack.last() { + terminal.draw(|frame| top.render(frame, &ctx))?; + + // Wait for input until the next tick is due, then take whatever else + // is already queued without waiting again. + let mut timeout = next_tick.saturating_duration_since(Instant::now()); + while event::poll(timeout)? { + timeout = Duration::ZERO; + let Event::Key(key) = event::read()? else { + continue; + }; + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + return Ok(()); + } + // Scenes get presses and auto-repeats. Releases only arrive on + // terminals with `ctx.keyboard.key_release`; when flight needs + // held keys, track them here before this filter. + if key.kind == KeyEventKind::Release { + continue; + } + let Some(top) = stack.last_mut() else { break }; + let transition = top.key(key, &mut ctx); + apply(&mut stack, transition); + } + + let mut caught_up = 0; + while Instant::now() >= next_tick { + let Some(top) = stack.last_mut() else { break }; + ctx.ticks += 1; + let transition = top.tick(&mut ctx); + apply(&mut stack, transition); + next_tick += TICK; + caught_up += 1; + if caught_up == MAX_CATCH_UP { + next_tick = Instant::now() + TICK; + } + } + } + Ok(()) +} + +fn apply(stack: &mut Vec>, transition: Transition) { + match transition { + Transition::None => {} + Transition::Push(scene) => stack.push(scene), + Transition::Pop => { + stack.pop(); + } + Transition::Quit => stack.clear(), + } +} -- cgit v1.2.3