diff options
| author | skylar <me@casper> | 2026-11-10 06:50:08 -0800 |
|---|---|---|
| committer | skylar <me@casper> | 2026-11-10 06:50:08 -0800 |
| commit | d36c54d996f3c14ae811312dbbe621e12a39edde (patch) | |
| tree | 1807134323479ac90bf69d28e410a7f70cb235a1 /src/scenes/forge.rs | |
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 <noreply@anthropic.com>
Diffstat (limited to 'src/scenes/forge.rs')
| -rw-r--r-- | src/scenes/forge.rs | 154 |
1 files changed, 154 insertions, 0 deletions
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<String>, + places: Vec<String>, +} + +pub struct Forge { + forging: u64, + columns: Vec<Column>, + artifacts: Vec<Artifact>, +} + +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<Rect> = 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<Line> = self + .artifacts + .iter() + .map(|a| { + Line::from(vec![ + Span::from(format!("{:<width$}", a.unidentified)).italic().gray(), + Span::from(" → ").dark_gray(), + Span::from(a.true_name.as_str()).light_yellow(), + ]) + }) + .collect(); + let block = Block::bordered() + .border_style(Style::new().dark_gray()) + .title(Line::from(" ARTIFACTS: as found → once identified ").bold()); + frame.render_widget(Paragraph::new(lines).block(block), artifacts); + + frame.render_widget( + Paragraph::new(" [space/→] forge again [←] previous [esc] back").dark_gray(), + footer, + ); + } +} |