//! 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)>, } 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::>(), ) }; 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 = 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) { 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 = 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?")); } }