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/derelict.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/derelict.rs')
| -rw-r--r-- | src/scenes/derelict.rs | 413 |
1 files changed, 413 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?")); + } +} |