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 | |
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.rs | 413 | ||||
| -rw-r--r-- | src/scenes/forge.rs | 154 | ||||
| -rw-r--r-- | src/scenes/mod.rs | 74 | ||||
| -rw-r--r-- | src/scenes/title.rs | 224 |
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, + ); + } +} |