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/scenes/forge.rs | 154 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 src/scenes/forge.rs (limited to 'src/scenes/forge.rs') diff --git a/src/scenes/forge.rs b/src/scenes/forge.rs new file mode 100644 index 0000000..f856c8f --- /dev/null +++ b/src/scenes/forge.rs @@ -0,0 +1,154 @@ +//! The name forge: browse what the name generator makes for each culture. +//! +//! Each forging is `derive(universe, "forge", n)`, so stepping back with ← +//! regenerates exactly what you saw before. + +use ratatui::Frame; +use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Paragraph, Wrap}; + +use super::{Scene, Transition}; +use crate::app::Ctx; +use crate::names::{self, Artifact, Culture}; +use crate::seed; + +const PER_LIST: usize = 4; +const ARTIFACTS: usize = 5; + +struct Column { + culture: Culture, + people: Vec, + places: Vec, +} + +pub struct Forge { + forging: u64, + columns: Vec, + artifacts: Vec, +} + +impl Forge { + pub fn new(ctx: &Ctx) -> Self { + let mut forge = Forge { forging: 0, columns: Vec::new(), artifacts: Vec::new() }; + forge.forge(ctx.seed); + forge + } + + fn forge(&mut self, universe: u64) { + let mut rng = seed::rng(seed::derive(universe, "forge", self.forging)); + self.columns = Culture::ALL + .iter() + .map(|&culture| Column { + culture, + people: (0..PER_LIST).map(|_| culture.person(&mut rng)).collect(), + places: (0..PER_LIST).map(|_| culture.place(&mut rng)).collect(), + }) + .collect(); + self.artifacts = (0..ARTIFACTS).map(|_| names::artifact(&mut rng)).collect(); + } +} + +fn colour(culture: Culture) -> Color { + match culture { + Culture::Qaari => Color::Yellow, + Culture::Cant => Color::Red, + Culture::Houses => Color::Magenta, + Culture::Drift => Color::Cyan, + } +} + +fn render_column(frame: &mut Frame, column: &Column, area: Rect) { + let tint = colour(column.culture); + let heading = |text: &'static str| Line::from(text).fg(tint).dim(); + let mut lines = vec![ + Line::from(column.culture.blurb()).dark_gray().italic(), + Line::default(), + heading("PEOPLE"), + ]; + lines.extend(column.people.iter().map(|n| Line::from(n.as_str()))); + lines.push(Line::default()); + lines.push(heading("PLACES")); + lines.extend(column.places.iter().map(|n| Line::from(n.as_str()))); + + let block = Block::bordered() + .border_style(Style::new().fg(tint).dim()) + .title(Line::from(format!(" {} ", column.culture.name())).fg(tint).bold()); + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }).block(block), area); +} + +impl Scene for Forge { + fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { + match key.code { + KeyCode::Char(' ') | KeyCode::Right | KeyCode::Enter => { + self.forging += 1; + self.forge(ctx.seed); + } + KeyCode::Left | KeyCode::Backspace if self.forging > 0 => { + self.forging -= 1; + self.forge(ctx.seed); + } + KeyCode::Esc => return Transition::Pop, + KeyCode::Char(c) if c.eq_ignore_ascii_case(&'q') => return Transition::Pop, + _ => {} + } + Transition::None + } + + fn render(&self, frame: &mut Frame, ctx: &Ctx) { + let [header, cultures, artifacts, footer] = Layout::vertical([ + Constraint::Length(2), + Constraint::Fill(1), + Constraint::Length(ARTIFACTS as u16 + 2), + Constraint::Length(1), + ]) + .areas(frame.area()); + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::from(" THE NAME FORGE").bold(), + Span::from(format!(" forging {} of universe {}", self.forging, ctx.seed)) + .dark_gray(), + ])), + header, + ); + + // Four across on wide terminals, two by two otherwise. + let cells: Vec = if cultures.width >= 120 { + Layout::horizontal([Constraint::Fill(1); 4]).split(cultures).to_vec() + } else { + Layout::vertical([Constraint::Fill(1); 2]) + .split(cultures) + .iter() + .flat_map(|row| Layout::horizontal([Constraint::Fill(1); 2]).split(*row).to_vec()) + .collect() + }; + for (column, cell) in self.columns.iter().zip(cells) { + render_column(frame, column, cell); + } + + let width = self.artifacts.iter().map(|a| a.unidentified.len()).max().unwrap_or(0); + let lines: Vec = self + .artifacts + .iter() + .map(|a| { + Line::from(vec![ + Span::from(format!("{: