summaryrefslogtreecommitdiff
path: root/src/scenes
diff options
context:
space:
mode:
authorskylar <me@casper>2026-11-10 06:50:08 -0800
committerskylar <me@casper>2026-11-10 06:50:08 -0800
commitd36c54d996f3c14ae811312dbbe621e12a39edde (patch)
tree1807134323479ac90bf69d28e410a7f70cb235a1 /src/scenes
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')
-rw-r--r--src/scenes/derelict.rs413
-rw-r--r--src/scenes/forge.rs154
-rw-r--r--src/scenes/mod.rs74
-rw-r--r--src/scenes/title.rs224
4 files changed, 865 insertions, 0 deletions
diff --git a/src/scenes/derelict.rs b/src/scenes/derelict.rs
new file mode 100644
index 0000000..fc18f50
--- /dev/null
+++ b/src/scenes/derelict.rs
@@ -0,0 +1,413 @@
+//! Aboard a derelict: the map, your status, and the log. The rules live in
+//! `dungeon::crawl`; this turns keys into actions and draws the result.
+
+use ratatui::Frame;
+use ratatui::buffer::Buffer;
+use ratatui::crossterm::event::{KeyCode, KeyEvent};
+use ratatui::layout::{Constraint, Flex, Layout, Rect};
+use ratatui::style::{Color, Style, Stylize};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Wrap};
+
+use super::{Scene, Transition};
+use crate::app::Ctx;
+use crate::dungeon::crawl::{Action, Crawl, Item, Outcome, SIGHT_RADIUS};
+use crate::dungeon::{Pos, Tile};
+use crate::seed;
+
+const PANEL_WIDTH: u16 = 34;
+const LOG_HEIGHT: u16 = 7;
+
+type Rgb = (u8, u8, u8);
+/// Cells you've seen but can't see now.
+const REMEMBERED: Rgb = (64, 68, 84);
+/// The hull's outline, seen from outside as you docked.
+const SILHOUETTE: Rgb = (40, 44, 60);
+const STAR: Rgb = (72, 72, 92);
+
+pub struct Derelict {
+ crawl: Crawl,
+ /// Asking whether to leave by the emergency hatch.
+ abandoning: bool,
+ /// Once the crawl is over: a title and lines for the closing summary.
+ ending: Option<(&'static str, Vec<String>)>,
+}
+
+impl Derelict {
+ pub fn new(seed: u64) -> Self {
+ Derelict { crawl: Crawl::new(seed), abandoning: false, ending: None }
+ }
+
+ /// Called once, when the crawl ends: banks what you carried if you got
+ /// out, and writes the summary.
+ fn finish(&mut self, outcome: Outcome, ctx: &mut Ctx) {
+ let c = &self.crawl;
+ let lines = match outcome {
+ Outcome::Escaped => {
+ ctx.hold.artifacts.extend(c.artifacts.iter().cloned());
+ ctx.hold.scrip += c.scrip;
+ let mut lines = vec![
+ format!("You cast off from {}.", c.name),
+ String::new(),
+ format!(
+ "Recovered {} and {} ¤ of salvage.",
+ count(c.artifacts.len(), "artifact"),
+ c.scrip
+ ),
+ ];
+ lines.extend(c.artifacts.iter().map(|a| format!(" * {}", a.unidentified)));
+ lines.push(String::new());
+ lines.push(format!(
+ "Your hold now carries {} and {} ¤.",
+ count(ctx.hold.artifacts.len(), "artifact"),
+ ctx.hold.scrip
+ ));
+ lines
+ }
+ Outcome::Died { killer } => vec![
+ format!("The {killer} brings you down aboard {}.", c.name),
+ "Your crew drags you back to your ship, but".into(),
+ "everything you carried stays with the wreck.".into(),
+ ],
+ };
+ let title = match outcome {
+ Outcome::Escaped => " SAFE ABOARD YOUR SHIP ",
+ Outcome::Died { .. } => " PULLED BACK FROM THE EDGE ",
+ };
+ self.ending = Some((title, lines));
+ }
+
+ fn render_map(&self, buf: &mut Buffer, area: Rect) {
+ let c = &self.crawl;
+ let left = camera(c.player.x, c.map.width, i32::from(area.width));
+ let top = camera(c.player.y, c.map.height, i32::from(area.height));
+ for sy in 0..area.height {
+ for sx in 0..area.width {
+ let p = Pos::new(left + i32::from(sx), top + i32::from(sy));
+ let (glyph, style) = self.look(p);
+ if let Some(cell) = buf.cell_mut((area.x + sx, area.y + sy)) {
+ cell.set_char(glyph).set_style(style);
+ }
+ }
+ }
+ }
+
+ /// How the cell at `p` looks from where you stand: lit by your lamp if
+ /// you can see it, from memory if you've seen it, and otherwise only the
+ /// hull's outline against the stars.
+ fn look(&self, p: Pos) -> (char, Style) {
+ let c = &self.crawl;
+ let tile = c.map.tile(p);
+ if c.fov.contains(p) {
+ if p == c.player {
+ return ('@', Style::new().fg(Color::White).bold());
+ }
+ if let Some(m) = c.monster_at(p) {
+ return (m.kind.glyph, Style::new().fg(rgb(m.kind.rgb, 1.0)).bold());
+ }
+ let dist = f64::from((p.x - c.player.x).pow(2) + (p.y - c.player.y).pow(2)).sqrt();
+ let light = 1.0 - 0.6 * (dist / f64::from(SIGHT_RADIUS)).min(1.0);
+ if let Some(loot) = c.loot_at(p) {
+ let (glyph, colour) = item_look(&loot.item);
+ return (glyph, Style::new().fg(rgb(colour, light.max(0.8))).bold());
+ }
+ let (glyph, colour) = tile_look(tile);
+ return (glyph, Style::new().fg(rgb(colour, light)));
+ }
+ if c.seen(p) {
+ if let Some(loot) = c.loot_at(p) {
+ let (glyph, colour) = item_look(&loot.item);
+ return (glyph, Style::new().fg(rgb(colour, 0.45)));
+ }
+ return (tile_look(tile).0, Style::new().fg(rgb(REMEMBERED, 1.0)));
+ }
+ match tile {
+ Tile::Hull | Tile::Airlock => {
+ (tile_look(tile).0, Style::new().fg(rgb(SILHOUETTE, 1.0)))
+ }
+ Tile::Void if is_star(p) => ('.', Style::new().fg(rgb(STAR, 1.0))),
+ _ => (' ', Style::new()),
+ }
+ }
+
+ fn render_panel(&self, frame: &mut Frame, area: Rect) {
+ let c = &self.crawl;
+ let block = Block::bordered()
+ .border_style(Style::new().dark_gray())
+ .title(Line::from(" DERELICT ").bold())
+ .padding(Padding::horizontal(1));
+ let inner = block.inner(area);
+ frame.render_widget(block, area);
+ let [info, help] =
+ Layout::vertical([Constraint::Fill(1), Constraint::Length(5)]).areas(inner);
+
+ let label = |text: &'static str| Span::from(text).dark_gray();
+ let mut lines = vec![
+ Line::from(c.name.as_str()).bold().fg(Color::Yellow),
+ Line::from(c.description.as_str()).italic().dark_gray(),
+ Line::default(),
+ Line::from(vec![label("health "), health_bar(c.hp, c.max_hp, 12)]),
+ Line::from(vec![label("medpatches"), Span::from(format!(" {}", c.medpatches))]),
+ Line::from(vec![label("here "), Span::from(c.location())]),
+ Line::from(vec![label("turn "), Span::from(c.turns.to_string())]),
+ Line::default(),
+ Line::from(vec![label("salvage "), Span::from(format!("{} ¤", c.scrip))]),
+ Line::from(vec![label("artifacts "), Span::from(c.artifacts.len().to_string())]),
+ ];
+ for artifact in &c.artifacts {
+ lines.push(Line::from(format!("* {}", artifact.unidentified)).fg(Color::Yellow));
+ }
+ frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), info);
+
+ // Pairs of (key, what it does) on one line.
+ let keys = |pairs: &[(&'static str, &'static str)]| {
+ Line::from(
+ pairs
+ .iter()
+ .flat_map(|&(k, what)| [Span::from(k).bold(), Span::from(what).dark_gray()])
+ .collect::<Vec<_>>(),
+ )
+ };
+ frame.render_widget(
+ Paragraph::new(vec![
+ keys(&[("arrows hjkl yubn", " move")]),
+ keys(&[(".", " wait "), ("m", " medpatch")]),
+ keys(&[("walk into Θ", " leave with loot")]),
+ keys(&[("esc", " abandon ship, loot and all")]),
+ ]),
+ help,
+ );
+ }
+
+ fn render_log(&self, frame: &mut Frame, area: Rect) {
+ let log = &self.crawl.log;
+ let shown = usize::from(area.height.saturating_sub(1));
+ let first = log.len().saturating_sub(shown);
+ let lines: Vec<Line> = log[first..]
+ .iter()
+ .enumerate()
+ .map(|(i, message)| {
+ let age = log.len() - first - 1 - i;
+ let line = Line::from(message.as_str());
+ match age {
+ 0 => line.white(),
+ 1 | 2 => line.gray(),
+ _ => line.dark_gray(),
+ }
+ })
+ .collect();
+ let block = Block::new().borders(Borders::TOP).border_style(Style::new().dark_gray());
+ frame.render_widget(Paragraph::new(lines).block(block), area);
+ }
+}
+
+impl Scene for Derelict {
+ fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition {
+ if self.ending.is_some() {
+ return Transition::Pop;
+ }
+ if self.abandoning {
+ self.abandoning = false;
+ return match key.code {
+ KeyCode::Char('y' | 'Y') => Transition::Pop,
+ _ => Transition::None,
+ };
+ }
+ // Vi keys, arrows, and the number pad with or without num lock.
+ let action = match key.code {
+ KeyCode::Left | KeyCode::Char('h' | '4') => Action::Move(-1, 0),
+ KeyCode::Right | KeyCode::Char('l' | '6') => Action::Move(1, 0),
+ KeyCode::Up | KeyCode::Char('k' | '8') => Action::Move(0, -1),
+ KeyCode::Down | KeyCode::Char('j' | '2') => Action::Move(0, 1),
+ KeyCode::Home | KeyCode::Char('y' | '7') => Action::Move(-1, -1),
+ KeyCode::PageUp | KeyCode::Char('u' | '9') => Action::Move(1, -1),
+ KeyCode::End | KeyCode::Char('b' | '1') => Action::Move(-1, 1),
+ KeyCode::PageDown | KeyCode::Char('n' | '3') => Action::Move(1, 1),
+ KeyCode::Char('.' | '5') => Action::Wait,
+ KeyCode::Char('m') => Action::UseMedpatch,
+ KeyCode::Esc => {
+ self.abandoning = true;
+ return Transition::None;
+ }
+ _ => return Transition::None,
+ };
+ self.crawl.act(action);
+ if let Some(outcome) = self.crawl.outcome {
+ self.finish(outcome, ctx);
+ }
+ Transition::None
+ }
+
+ fn render(&self, frame: &mut Frame, _ctx: &Ctx) {
+ let [main, panel] =
+ Layout::horizontal([Constraint::Fill(1), Constraint::Length(PANEL_WIDTH)])
+ .areas(frame.area());
+ let [map, log] =
+ Layout::vertical([Constraint::Fill(1), Constraint::Length(LOG_HEIGHT)]).areas(main);
+ self.render_map(frame.buffer_mut(), map);
+ self.render_log(frame, log);
+ self.render_panel(frame, panel);
+
+ if self.abandoning {
+ popup(
+ frame,
+ " ABANDON SHIP? ",
+ vec![
+ format!("Leave {} by the emergency hatch?", self.crawl.name),
+ "Whatever you're carrying stays behind.".into(),
+ String::new(),
+ "[y] abandon [any other key] keep exploring".into(),
+ ],
+ );
+ } else if let Some((title, lines)) = &self.ending {
+ let mut lines = lines.clone();
+ lines.extend([String::new(), "[any key]".into()]);
+ popup(frame, title, lines);
+ }
+ }
+}
+
+/// The left or top edge of the view: keeps `focus` centred, but doesn't
+/// scroll past the map's edges, and centres a map smaller than the view.
+fn camera(focus: i32, map_size: i32, view_size: i32) -> i32 {
+ if map_size <= view_size {
+ -(view_size - map_size) / 2
+ } else {
+ (focus - view_size / 2).clamp(0, map_size - view_size)
+ }
+}
+
+pub(crate) fn tile_look(tile: Tile) -> (char, Rgb) {
+ match tile {
+ Tile::Void => (' ', (0, 0, 0)),
+ Tile::Hull => ('#', (122, 134, 160)),
+ Tile::Wall => ('#', (152, 148, 136)),
+ Tile::Floor => ('.', (124, 118, 104)),
+ Tile::Debris => (',', (160, 126, 92)),
+ Tile::Door { open: false } => ('+', (216, 170, 86)),
+ Tile::Door { open: true } => ('\'', (216, 170, 86)),
+ Tile::Airlock => ('Θ', (90, 222, 200)),
+ }
+}
+
+pub(crate) fn item_look(item: &Item) -> (char, Rgb) {
+ match item {
+ Item::Artifact(_) => ('*', (255, 214, 92)),
+ Item::Salvage { .. } => ('%', (150, 200, 222)),
+ Item::Medpatch => ('!', (240, 96, 96)),
+ }
+}
+
+fn rgb((r, g, b): Rgb, light: f64) -> Color {
+ let scale = |c: u8| (f64::from(c) * light).round() as u8;
+ Color::Rgb(scale(r), scale(g), scale(b))
+}
+
+/// A sprinkling of stars that stays put as the view scrolls.
+fn is_star(p: Pos) -> bool {
+ let cell = (u64::from(p.x as u32) << 32) | u64::from(p.y as u32);
+ seed::derive(cell, "star", 0).is_multiple_of(41)
+}
+
+fn health_bar(hp: i32, max: i32, width: i32) -> Span<'static> {
+ let filled = (hp * width + max - 1) / max;
+ let colour = match hp * 4 / max {
+ 3.. => Color::Green,
+ 2 => Color::Yellow,
+ _ => Color::Red,
+ };
+ let bar = "█".repeat(filled as usize) + &"░".repeat((width - filled) as usize);
+ Span::styled(format!("{bar} {hp}/{max}"), Style::new().fg(colour))
+}
+
+fn count(n: usize, noun: &str) -> String {
+ if n == 1 { format!("1 {noun}") } else { format!("{n} {noun}s") }
+}
+
+fn popup(frame: &mut Frame, title: &str, lines: Vec<String>) {
+ let area = frame.area();
+ let widest = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0) as u16;
+ let width = (widest + 4).min(area.width);
+ let height = (lines.len() as u16 + 2).min(area.height);
+ let [popup] = Layout::horizontal([Constraint::Length(width)]).flex(Flex::Center).areas(area);
+ let [popup] = Layout::vertical([Constraint::Length(height)]).flex(Flex::Center).areas(popup);
+ let block = Block::bordered()
+ .border_style(Style::new().fg(Color::Yellow))
+ .title(Line::from(title.to_string()).bold())
+ .padding(Padding::horizontal(1));
+ let lines: Vec<Line> = lines.into_iter().map(Line::from).collect();
+ frame.render_widget(Clear, popup);
+ frame.render_widget(Paragraph::new(lines).block(block), popup);
+}
+
+#[cfg(test)]
+mod tests {
+ use ratatui::Terminal;
+ use ratatui::backend::TestBackend;
+ use ratatui::crossterm::event::KeyModifiers;
+
+ use super::*;
+ use crate::term::KeyboardCaps;
+
+ fn ctx() -> Ctx {
+ Ctx::new(1, KeyboardCaps { key_release: false })
+ }
+
+ fn press(scene: &mut Derelict, code: KeyCode, ctx: &mut Ctx) -> Transition {
+ scene.key(KeyEvent::new(code, KeyModifiers::NONE), ctx)
+ }
+
+ fn screen(scene: &Derelict, ctx: &Ctx) -> String {
+ let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
+ terminal.draw(|frame| scene.render(frame, ctx)).unwrap();
+ terminal.backend().buffer().content().iter().map(|cell| cell.symbol()).collect()
+ }
+
+ /// The vi key that moves you onto the airlock from the starting cell.
+ fn key_toward_airlock(scene: &Derelict) -> KeyCode {
+ let c = &scene.crawl;
+ let airlock = c.player.neighbours().find(|&n| c.map.tile(n) == Tile::Airlock).unwrap();
+ KeyCode::Char(match (airlock.x - c.player.x, airlock.y - c.player.y) {
+ (-1, -1) => 'y',
+ (0, -1) => 'k',
+ (1, -1) => 'u',
+ (-1, 0) => 'h',
+ (1, 0) => 'l',
+ (-1, 1) => 'b',
+ (0, 1) => 'j',
+ _ => 'n',
+ })
+ }
+
+ #[test]
+ fn escaping_banks_what_you_carry() {
+ let mut ctx = ctx();
+ let mut scene = Derelict::new(3);
+ scene.crawl.scrip = 55;
+ let toward = key_toward_airlock(&scene);
+ assert!(matches!(press(&mut scene, toward, &mut ctx), Transition::None));
+ assert_eq!(ctx.hold.scrip, 55);
+ assert!(screen(&scene, &ctx).contains("SAFE ABOARD YOUR SHIP"));
+ assert!(matches!(press(&mut scene, KeyCode::Char(' '), &mut ctx), Transition::Pop));
+ }
+
+ #[test]
+ fn abandoning_leaves_it_behind() {
+ let mut ctx = ctx();
+ let mut scene = Derelict::new(3);
+ scene.crawl.scrip = 55;
+ press(&mut scene, KeyCode::Esc, &mut ctx);
+ assert!(screen(&scene, &ctx).contains("ABANDON SHIP?"));
+ assert!(matches!(press(&mut scene, KeyCode::Char('y'), &mut ctx), Transition::Pop));
+ assert_eq!(ctx.hold.scrip, 0);
+ }
+
+ #[test]
+ fn any_other_key_keeps_exploring() {
+ let mut ctx = ctx();
+ let mut scene = Derelict::new(3);
+ press(&mut scene, KeyCode::Esc, &mut ctx);
+ assert!(matches!(press(&mut scene, KeyCode::Char('x'), &mut ctx), Transition::None));
+ assert!(!screen(&scene, &ctx).contains("ABANDON SHIP?"));
+ }
+}
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,
+ );
+ }
+}
diff --git a/src/scenes/mod.rs b/src/scenes/mod.rs
new file mode 100644
index 0000000..60db647
--- /dev/null
+++ b/src/scenes/mod.rs
@@ -0,0 +1,74 @@
+//! Scenes: the title screen, the name forge, and derelicts to board;
+//! flight and ports later.
+
+pub mod derelict;
+pub mod forge;
+pub mod title;
+
+use ratatui::Frame;
+use ratatui::crossterm::event::KeyEvent;
+
+use crate::app::Ctx;
+
+/// What a scene wants to happen after it handles a key or a tick.
+pub enum Transition {
+ None,
+ /// Open a scene on top of this one. Popping it comes back here.
+ Push(Box<dyn Scene>),
+ /// Close this scene and return to the one beneath it.
+ Pop,
+ Quit,
+}
+
+/// One screen of the game. The game keeps a stack of them; only the top one
+/// gets keys and ticks and is drawn.
+pub trait Scene {
+ /// A key was pressed, or auto-repeated while held.
+ fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition;
+
+ /// One fixed step of game time; there are `app::TICKS_PER_SECOND` of
+ /// these a second, whatever the frame rate. Turn-based scenes can ignore it.
+ fn tick(&mut self, _ctx: &mut Ctx) -> Transition {
+ Transition::None
+ }
+
+ fn render(&self, frame: &mut Frame, ctx: &Ctx);
+}
+
+#[cfg(test)]
+mod tests {
+ use ratatui::Terminal;
+ use ratatui::backend::TestBackend;
+ use ratatui::crossterm::event::{KeyCode, KeyModifiers};
+
+ use super::*;
+ use crate::term::KeyboardCaps;
+
+ pub fn test_ctx() -> Ctx {
+ Ctx::new(1, KeyboardCaps { key_release: false })
+ }
+
+ /// Draws every scene at several sizes, down to absurdly small ones, to
+ /// catch layout arithmetic that panics.
+ #[test]
+ fn scenes_render_at_any_size() {
+ let mut ctx = test_ctx();
+ let mut abandoning = derelict::Derelict::new(2);
+ abandoning.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), &mut ctx);
+ let scenes: [Box<dyn Scene>; 4] = [
+ Box::new(title::Title::new(&ctx)),
+ Box::new(forge::Forge::new(&ctx)),
+ Box::new(derelict::Derelict::new(1)),
+ Box::new(abandoning),
+ ];
+ for (width, height) in [(160, 50), (80, 24), (40, 12), (5, 3), (1, 1)] {
+ let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
+ for scene in &scenes {
+ for ticks in [0, 12_345] {
+ ctx.ticks = ticks;
+ terminal.draw(|frame| scene.render(frame, &ctx)).unwrap();
+ }
+ }
+ }
+ }
+}
diff --git a/src/scenes/title.rs b/src/scenes/title.rs
new file mode 100644
index 0000000..7c75888
--- /dev/null
+++ b/src/scenes/title.rs
@@ -0,0 +1,224 @@
+//! Title screen: a turning orrery over a drifting starfield.
+//!
+//! 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.
+
+use std::f64::consts::TAU;
+
+use rand::RngExt;
+use ratatui::Frame;
+use ratatui::buffer::Buffer;
+use ratatui::crossterm::event::{KeyCode, KeyEvent};
+use ratatui::layout::{Constraint, Flex, Layout, Rect};
+use ratatui::style::{Color, Style, Stylize};
+use ratatui::symbols::Marker;
+use ratatui::text::{Line, Span};
+use ratatui::widgets::Paragraph;
+use ratatui::widgets::canvas::{Canvas, Points};
+
+use super::derelict::Derelict;
+use super::forge::Forge;
+use super::{Scene, Transition};
+use crate::app::{Ctx, TICKS_PER_SECOND};
+use crate::seed;
+
+const LOGO: [&str; 4] = [
+ r" ___ ___ ___ ___ ___ __ __",
+ r" / _ \ | _ \ | _ \ | __| | _ \ \ \ / /",
+ r"| (_) | | / | / | _| | / \ V / ",
+ r" \___/ |_|_\ |_|_\ |___| |_|_\ |_| ",
+];
+
+/// Star layers: drift speed in screen-widths per second, glyph, colour.
+/// Nearer layers move faster, which reads as depth.
+const STAR_LAYERS: [(f64, char, Color); 3] =
+ [(0.004, '.', Color::DarkGray), (0.010, '·', Color::Gray), (0.025, '*', Color::White)];
+
+/// The orrery is seen from above at an angle, so circular orbits look like
+/// ellipses this many times as tall as they are wide.
+const TILT: f64 = 0.4;
+
+const PLANET_COLOURS: [Color; 6] = [
+ Color::LightRed,
+ Color::LightBlue,
+ Color::LightGreen,
+ Color::LightMagenta,
+ Color::LightCyan,
+ Color::Rgb(205, 133, 63),
+];
+
+struct Star {
+ x: f64,
+ y: f64,
+ layer: usize,
+}
+
+struct Planet {
+ /// Orbit radius as a fraction of the largest orbit that fits.
+ orbit: f64,
+ /// Seconds per revolution.
+ period: f64,
+ phase: f64,
+ glyph: &'static str,
+ colour: Color,
+}
+
+pub struct Title {
+ stars: Vec<Star>,
+ planets: Vec<Planet>,
+}
+
+impl Title {
+ pub fn new(ctx: &Ctx) -> Self {
+ let mut rng = seed::rng(seed::derive(ctx.seed, "title", 0));
+ let stars = (0..160)
+ .map(|_| Star {
+ x: rng.random(),
+ y: rng.random(),
+ layer: rng.random_range(0..STAR_LAYERS.len()),
+ })
+ .collect();
+ let count = rng.random_range(3..=5);
+ let planets = (0..count)
+ .map(|i| {
+ let orbit = (i as f64 + rng.random_range(0.6..1.0)) / count as f64;
+ Planet {
+ orbit,
+ // Kepler's third law, roughly: outer planets are slower.
+ period: 45.0 * orbit.powf(1.5),
+ phase: rng.random_range(0.0..TAU),
+ glyph: if rng.random_bool(0.5) { "o" } else { "•" },
+ colour: PLANET_COLOURS[rng.random_range(..PLANET_COLOURS.len())],
+ }
+ })
+ .collect();
+ Title { stars, planets }
+ }
+
+ fn draw_stars(&self, buf: &mut Buffer, area: Rect, seconds: f64) {
+ for star in &self.stars {
+ let (speed, glyph, colour) = STAR_LAYERS[star.layer];
+ let x = (star.x - speed * seconds).rem_euclid(1.0);
+ let col = area.x + (x * f64::from(area.width)) as u16;
+ let row = area.y + (star.y * f64::from(area.height)) as u16;
+ if let Some(cell) = buf.cell_mut((col, row)) {
+ cell.set_char(glyph).set_fg(colour);
+ }
+ }
+ }
+
+ fn draw_orrery(&self, frame: &mut Frame, area: Rect, seconds: f64) {
+ // Braille gives each cell 2x4 dots. With these bounds one unit is one
+ // dot, and dots are about square, so geometry here needs no aspect
+ // correction (unlike plain cells, which are twice as tall as wide).
+ let half_w = f64::from(area.width);
+ let half_h = 2.0 * f64::from(area.height);
+ let max_radius = (half_w * 0.95).min(half_h * 0.9 / TILT);
+
+ let canvas = Canvas::default()
+ .marker(Marker::Braille)
+ .x_bounds([-half_w, half_w])
+ .y_bounds([-half_h, half_h])
+ .paint(|c| {
+ for planet in &self.planets {
+ let r = planet.orbit * max_radius;
+ let steps = (r * 2.0) as usize + 16;
+ let ring: Vec<(f64, f64)> = (0..steps)
+ .map(|i| {
+ let a = TAU * i as f64 / steps as f64;
+ (r * a.cos(), r * TILT * a.sin())
+ })
+ .collect();
+ c.draw(&Points { coords: &ring, color: Color::DarkGray });
+ }
+ c.print(0.0, 0.0, Span::styled("☼", Style::new().fg(Color::Yellow).bold()));
+ for planet in &self.planets {
+ let r = planet.orbit * max_radius;
+ let a = planet.phase + TAU * seconds / planet.period;
+ let glyph = Span::styled(planet.glyph, Style::new().fg(planet.colour).bold());
+ c.print(r * a.cos(), r * TILT * a.sin(), glyph);
+ }
+ });
+ frame.render_widget(canvas, area);
+ }
+}
+
+impl Scene for Title {
+ fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition {
+ match key.code {
+ KeyCode::Char(c) => match c.to_ascii_lowercase() {
+ 'd' => {
+ ctx.boarded += 1;
+ let ship = seed::derive(ctx.seed, "derelict", ctx.boarded);
+ Transition::Push(Box::new(Derelict::new(ship)))
+ }
+ 'n' => Transition::Push(Box::new(Forge::new(ctx))),
+ 'q' => Transition::Quit,
+ _ => Transition::None,
+ },
+ KeyCode::Esc => Transition::Quit,
+ _ => Transition::None,
+ }
+ }
+
+ 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 [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(1),
+ ])
+ .areas(area);
+
+ self.draw_orrery(frame, sky, seconds);
+
+ let logo_lines: Vec<Line> = 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" };
+ let text = format!(
+ "your hold: {} artifact{plural}, {} ¤ of salvage",
+ h.artifacts.len(),
+ h.scrip
+ );
+ 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"
+ };
+ frame.render_widget(
+ Paragraph::new(format!("universe {} · {keys}", ctx.seed)).centered().dark_gray(),
+ footer,
+ );
+ }
+}