//! 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, //! including the line of fire while you aim, and shots in flight. `i` opens //! the inventory (`scenes::inventory`), with what's on the floor beside it, //! and `c` your character sheet, with what you carry here. 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::character::Sheet; use super::inventory::{self, Beside, Command, Inventory, Reply, View}; use super::keys::{self, direction}; use super::pause::{Choice, Pause}; use super::{Scene, Transition}; use crate::app::Ctx; use crate::dungeon::arms::{Dmg, Weapon}; use crate::dungeon::crawl::{Action, Affinity, Crawl, Monster, Outcome, SIGHT_RADIUS, Trace}; use crate::dungeon::{Pos, Tile}; use crate::items::{self, Gear, Item, Stack}; use crate::seed; const PANEL_WIDTH: u16 = 34; const LOG_HEIGHT: u16 = 7; /// How fast shots cross the screen: cells for every two ticks. const SHOT_SPEED: u64 = 3; /// How long a shot's impact shows, in cells' worth of flight. const IMPACT: u64 = 6; /// Most monsters listed as in view. const IN_VIEW: usize = 6; /// The most steps a run takes. const RUN_LIMIT: usize = 80; 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); const LINE_OF_FIRE: Color = Color::Rgb(46, 40, 20); const RETICLE: Color = Color::Rgb(160, 128, 40); #[derive(Clone, Copy)] enum Mode { Explore, /// Picking where to fire. Aim(Pos), /// Asking whether to leave by the emergency hatch. Abandon, } pub struct Derelict { crawl: Crawl, mode: Mode, /// Open over the map, when you're looking through what you carry. inventory: Option, /// What you came aboard with, to tell what you found. brought: Gear, /// Once the crawl is over: a title and lines for the closing summary. ending: Option<(&'static str, Vec)>, /// The tick this turn's shots set off, for drawing them in flight. volley_at: u64, /// The menu `esc` opens. pause: Option, } impl Derelict { pub fn new(seed: u64) -> Self { Derelict { crawl: Crawl::new(seed), mode: Mode::Explore, inventory: None, brought: Gear::default(), ending: None, volley_at: 0, pause: None, } } /// Back aboard the wreck a save left you in, just as you left it: what /// you carried, what you'd seen, what's still hunting you. pub fn resumed(ctx: &Ctx) -> Option { let mut crawl = Crawl::resume(ctx.crawl.as_ref()?)?; crawl.known.clone_from(&ctx.bestiary); crawl.say(format!("You're back aboard {}, where you left off.", crawl.name)); let mut brought = ctx.hold.gear.clone(); brought.reload_all(); Some(Derelict { crawl, mode: Mode::Explore, inventory: None, brought, ending: None, volley_at: 0, pause: None, }) } /// Boards a derelict with everything you carry. Whatever's left of it /// comes back with you if you get out; your own torch and rivet gun come /// back whatever happens. What's in your ship's lockers stays safe /// aboard. What you know about what lives aboard wrecks, you know here /// too. /// /// The hold isn't touched until the crawl ends. The crawl itself is /// kept in `ctx.crawl` after every move, so a game saved halfway /// through (or quit) comes back aboard the wreck. pub fn board(seed: u64, ctx: &mut Ctx) -> Self { let mut derelict = Derelict::new(seed); ctx.record.boarded += 1; let c = &mut derelict.crawl; c.gear = ctx.hold.gear.clone(); c.gear.reload_all(); c.known.clone_from(&ctx.bestiary); derelict.brought = c.gear.clone(); let mut brought: Vec = everything(&c.gear).iter().filter(|s| !s.item.is_kit()).map(Stack::name).collect(); if !brought.is_empty() { if brought.len() > 4 { brought.truncate(3); brought.push("more".into()); } let brought = listed(&brought); c.say(format!("You've brought {brought}.")); } ctx.crawl = Some(derelict.crawl.saved()); derelict } /// Takes a turn (or doesn't), and keeps up with what came of it. fn act(&mut self, action: Action, ctx: &mut Ctx) { self.crawl.act(action); if !self.crawl.volley.is_empty() { self.volley_at = ctx.ticks; } ctx.bestiary.extend(self.crawl.known.iter().copied()); for kind in self.crawl.slain.drain(..) { *ctx.record.slain.entry(kind.to_string()).or_default() += 1; } ctx.crawl = self.crawl.outcome.is_none().then(|| self.crawl.saved()); if let Some(outcome) = self.crawl.outcome && self.ending.is_none() { self.mode = Mode::Explore; self.finish(outcome, ctx); } } /// 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.record.escaped += 1; ctx.hold.gear = c.gear.clone(); let found = gained(&self.brought, &c.gear); let mut lines = vec![format!("You cast off from {}.", c.name), String::new()]; if found.is_empty() { lines.push("You come away with nothing new.".into()); } else { lines.push("You come away with:".into()); lines.extend(found.iter().map(|s| { let (glyph, _) = inventory::look(&s.item); format!(" {glyph} {}", s.name()) })); } let worth: u32 = found .iter() .map(|s| match &s.item { Item::Salvage(v) => v.value * s.count, _ => 0, }) .sum(); if worth > 0 { lines.push(format!("The salvage is worth about {worth} ¤ to a broker.")); } lines.push(String::new()); lines.push(format!( "You carry {} of {} kg. Stow what you don't need in the lockers [i].", items::kg(c.gear.carried()), items::kg(c.gear.capacity) )); lines } Outcome::Died { killer } => { ctx.record.downed += 1; ctx.hold.lose_carried(&c.gear); vec![ format!("The {killer} brings you down aboard {}.", c.name), format!("Your crew drags you back to {}, but", ctx.character.ship_name()), "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)); // You may well be holding the key that walked you into the // airlock: its repeats mustn't wave the summary away unread. ctx.settle(); } /// Shift and a direction: steps that way until there's something to /// stop for: something in sight, a wound, something said or underfoot, /// a doorway, another room, a closed door or the airlock ahead. With /// something in sight already, it's just a step. fn run(&mut self, dx: i32, dy: i32, ctx: &mut Ctx) { for step in 0..RUN_LIMIT { let c = &self.crawl; // Opening a door and leaving are steps you take, not ones a // run carries you into. let ahead = c.map.tile(c.player.offset(dx, dy)); if step > 0 && matches!(ahead, Tile::Airlock | Tile::Door { open: false }) { break; } let (from, hp, said) = (c.player, c.hp, c.said); let room = c.map.room_index(c.player); self.act(Action::Move(dx, dy), ctx); let c = &self.crawl; let stop = c.outcome.is_some() || c.player == from || c.hp < hp || c.said != said || c.monsters.iter().any(|m| c.fov.contains(m.pos)) || !c.here().is_empty() || matches!(c.map.tile(c.player), Tile::Door { .. }) // Out of a doorway, the first step is into the room. || (step > 0 && c.map.room_index(c.player) != room); if stop { break; } } } fn explore(&mut self, code: KeyCode) -> Option { if let Some((dx, dy)) = direction(code) { return Some(Action::Move(dx, dy)); } match code { KeyCode::Char('.' | '5') => Some(Action::Wait), KeyCode::Char('a') => Some(Action::UseMedpatch), KeyCode::Char('r') => Some(Action::Reload), KeyCode::Char('g' | ',') if self.crawl.here().len() > 1 => { self.inventory = Some(Inventory::beside()); None } KeyCode::Char('g' | ',') => Some(Action::PickUp), KeyCode::Char('f') if self.crawl.gear.gun.loaded == 0 => Some(Action::Reload), KeyCode::Char('f') => { let at = self.targets().first().copied().unwrap_or(self.crawl.player); self.mode = Mode::Aim(at); None } KeyCode::Char('i') => { self.inventory = Some(Inventory::new()); None } KeyCode::Esc => { let note = format!( "Aboard {}. Quit, and you'll come back aboard just as you left it.", self.crawl.name ); self.pause = Some(Pause::new(Some("Abandon ship"), Some(note))); None } _ => None, } } fn aim(&mut self, code: KeyCode, at: Pos) -> Option { if let Some((dx, dy)) = direction(code) { let to = at.offset(dx, dy); if self.crawl.map.contains(to) { self.mode = Mode::Aim(to); } return None; } match code { KeyCode::Tab | KeyCode::Char('+' | '=') => self.mode = Mode::Aim(self.cycle(at, 1)), KeyCode::BackTab | KeyCode::Char('-') => self.mode = Mode::Aim(self.cycle(at, -1)), KeyCode::Enter | KeyCode::Char('f' | 't') if at != self.crawl.player => { self.mode = Mode::Explore; return Some(Action::Fire(at)); } KeyCode::Esc | KeyCode::Char('q') => self.mode = Mode::Explore, _ => {} } None } /// Where the things you can see are, nearest first. fn targets(&self) -> Vec { let c = &self.crawl; let mut targets: Vec = c.monsters.iter().map(|m| m.pos).filter(|&p| c.fov.contains(p)).collect(); targets.sort_by_key(|&p| (distance2(c.player, p), p.y, p.x)); targets } /// The next target after (or before) the one at `at`. fn cycle(&self, at: Pos, by: isize) -> Pos { let targets = self.targets(); match targets.iter().position(|&p| p == at) { _ if targets.is_empty() => at, Some(i) => targets[(i as isize + by).rem_euclid(targets.len() as isize) as usize], None => targets[0], } } fn render_map(&self, buf: &mut Buffer, area: Rect, ticks: u64) { 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)); let flying = self.in_flight(ticks); let (aim, line) = match self.mode { Mode::Aim(at) => (Some(at), c.line_of_fire(at)), _ => (None, Vec::new()), }; 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 (mut glyph, mut style) = self.look(p); if line.contains(&p) { style = style.bg(LINE_OF_FIRE); } if aim == Some(p) { style = style.bg(RETICLE); if p != c.player && c.monster_at(p).is_none() { (glyph, style) = ('×', style.fg(Color::Black)); } } if let Some(&(_, g, colour)) = flying.iter().find(|(q, ..)| *q == p) { glyph = g; style = style.fg(rgb(colour, 1.0)).bold(); } if let Some(cell) = buf.cell_mut((area.x + sx, area.y + sy)) { cell.set_char(glyph).set_style(style); } } } } /// What of this turn's shots is on screen `ticks` in: each round's head /// and a fading tail while it flies, then a flash where it stops. fn in_flight(&self, ticks: u64) -> Vec<(Pos, char, Rgb)> { let c = &self.crawl; let flown = ticks.saturating_sub(self.volley_at) * SHOT_SPEED / 2; let mut out = Vec::new(); for trace in &c.volley { let Some(flown) = flown.checked_sub(u64::from(trace.delay)) else { continue }; let (len, colour) = (trace.cells.len() as u64, trace.dmg.rgb()); let Some(&end) = trace.cells.last() else { continue }; if flown < len { out.push((trace.cells[flown as usize], shot_glyph(trace), colour)); if flown > 0 { out.push((trace.cells[flown as usize - 1], '·', dim(colour, 0.6))); } } else if flown < len + IMPACT { out.push((end, if trace.hit { '*' } else { '∙' }, colour)); } } out.retain(|(p, ..)| c.fov.contains(*p)); out } /// 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(distance2(p, c.player)).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) = inventory::look(&loot.stack.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) = inventory::look(&loot.stack.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 help = self.help(inner.width); let [info, keys] = Layout::vertical([Constraint::Fill(1), Constraint::Length(help.len() as u16)]) .areas(inner); 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)]), ]; if c.bleeding > 0 { lines.push(Line::from(vec![ label(" "), format!("bleeding {}", c.bleeding).red(), ])); } if c.burning > 0 { lines.push(Line::from(vec![ label(" "), Span::from(format!("alight {}", c.burning)).fg(rgb(Dmg::Burn.rgb(), 1.0)), ])); } lines.extend([ Line::from(vec![label("medpatches"), Span::from(format!(" {}", c.gear.medpatches()))]), Line::from(vec![label("here "), Span::from(c.location())]), Line::from(vec![label("turn "), Span::from(c.turns.to_string())]), Line::default(), ]); let (blade, gun) = (&c.gear.melee, &c.gear.gun); let blade_stats = format!("{} · {}%", blade.damage_text(), percent(blade.accuracy)); lines.extend([ Line::from(vec![label("melee "), title(blade, 20)]), Line::from(vec![label(" "), blade_stats.dark_gray()]), Line::from(vec![label("ranged "), title(gun, 20)]), Line::from(vec![ label(" "), magazine(gun), format!(" {}", gun.damage_text()).dark_gray(), ]), Line::from(vec![ label("carrying "), Span::from(format!( "{} / {} kg", items::kg(c.gear.carried()), items::kg(c.gear.capacity) )), ]), Line::default(), ]); match self.mode { Mode::Aim(at) => self.target_lines(at, &mut lines), _ => self.in_view_lines(&mut lines), } let found = gained(&self.brought, &c.gear); if !found.is_empty() { lines.push(Line::from("found").dark_gray()); } for stack in &found { let (glyph, colour) = inventory::look(&stack.item); lines.push(Line::from(vec![ Span::from(format!("{glyph} ")).fg(rgb(colour, 1.0)), Span::from(stack.name()), ])); } frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), info); frame.render_widget(Paragraph::new(help), keys); } /// The monsters you can see, nearest first, with how hurt they are. fn in_view_lines<'a>(&'a self, lines: &mut Vec>) { let c = &self.crawl; let mut seen: Vec<&Monster> = c.monsters.iter().filter(|m| c.fov.contains(m.pos)).collect(); if seen.is_empty() { return; } seen.sort_by_key(|m| distance2(c.player, m.pos)); lines.push(Line::from("in view").dark_gray()); for m in seen.iter().take(IN_VIEW) { let mut spans = vec![ Span::styled( m.kind.glyph.to_string(), Style::new().fg(rgb(m.kind.rgb, 1.0)).bold(), ), Span::from(format!(" {:<18}", m.kind.name)), mini_bar(m.hp, m.kind.hp, 6), ]; if m.bleeding > 0 { spans.push("•".red()); } if m.burning > 0 { spans.push(Span::from("*").fg(rgb(Dmg::Burn.rgb(), 1.0))); } if m.stunned > 0 { spans.push("z".yellow()); } lines.push(Line::from(spans)); } if seen.len() > IN_VIEW { lines.push(Line::from(format!(" and {} more", seen.len() - IN_VIEW)).dark_gray()); } lines.push(Line::default()); } /// What's under the reticle: how hurt it is, the odds, and what you /// know of how it takes each kind of damage. fn target_lines<'a>(&'a self, at: Pos, lines: &mut Vec>) { let c = &self.crawl; if at == c.player { lines.push(Line::from("Nothing in sight to shoot.").dark_gray()); lines.push(Line::from("Aim with the move keys.").dark_gray()); lines.push(Line::default()); return; } let away = f64::from(distance2(c.player, at)).sqrt().round(); let away = if away == 1.0 { "1 cell away".to_string() } else { format!("{away} cells away") }; let Some(m) = c.monster_at(at).filter(|m| c.fov.contains(m.pos)) else { lines.push(Line::from(vec![label("target "), Span::from("nothing there")])); let range = if c.in_range(at) { "" } else { " · out of range" }; lines.push(Line::from(format!("{away}{range}")).dark_gray()); lines.push(Line::default()); return; }; let kind = m.kind; lines.push(Line::from(vec![ label("target "), Span::styled(kind.name, Style::new().fg(rgb(kind.rgb, 1.0)).bold()), ])); let mut state = vec![mini_bar(m.hp, kind.hp, 12)]; if m.bleeding > 0 { state.push(format!(" bleeding {}", m.bleeding).red()); } if m.burning > 0 { state.push(Span::from(" burning").fg(rgb(Dmg::Burn.rgb(), 1.0))); } if m.stunned > 0 { state.push(" stunned".yellow()); } lines.push(Line::from(state)); let odds = match c.odds(at) { _ if !c.in_range(at) => "out of range".to_string(), Some(p) if c.gear.gun.gun.is_some_and(|g| g.pellets > 1) => { format!("{}% a pellet", percent(p)) } Some(p) => format!("{}% to hit", percent(p)), None => String::new(), }; lines.push(Line::from(format!("{away} · {odds}")).dark_gray()); let mut known: [(Affinity, Vec<&str>); 4] = [ (Affinity::Weak, Vec::new()), (Affinity::Resists, Vec::new()), (Affinity::Immune, Vec::new()), (Affinity::Normal, Vec::new()), ]; let mut untried = Vec::new(); for dmg in Dmg::ALL { match c.knows(kind, dmg) { Some(a) => known.iter_mut().find(|(k, _)| *k == a).unwrap().1.push(dmg.name()), None => untried.push(dmg.name()), } } for (affinity, dmgs) in known { if dmgs.is_empty() { continue; } let (word, colour) = match affinity { Affinity::Weak => ("weak to ", Color::Green), Affinity::Resists => ("resists ", Color::Yellow), Affinity::Immune => ("immune to ", Color::Red), Affinity::Normal => ("no matter: ", Color::Gray), }; lines.push(Line::from(vec![Span::from(word).fg(colour), Span::from(dmgs.join(", "))])); } if !untried.is_empty() { lines.push(Line::from(format!("untried: {}", untried.join(", "))).dark_gray()); } lines.push(Line::default()); } fn help(&self, width: u16) -> Vec> { let hints: &[(&str, &str)] = match self.mode { Mode::Aim(_) => &[ ("move keys", "aim"), ("tab", "next target"), ("f enter", "fire"), ("esc", "lower it"), ], _ => &[ ("arrows hjkl yubn", "move, attack"), ("shift", "run"), ("f", "fire"), ("r", "reload"), (".", "wait"), ("a", "medpatch"), ("g", "pick up"), ("i", "inventory"), ("c", "character"), ("into Θ", "leave with loot"), ("esc", "menu: quit or abandon ship"), ], }; keys::packed(hints, width) } 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); } } /// Everything on you, as stacks: worn, ready and packed. fn everything(gear: &Gear) -> Vec { let worn = gear.worn.values().map(|a| Stack::one(Item::Armor(a.clone()))); let ready = [&gear.melee, &gear.gun].into_iter().map(|w| Stack::one(Item::Weapon(w.clone()))); worn.chain(ready).chain(gear.pack.iter().cloned()).collect() } /// What you have now that you didn't come aboard with. fn gained(before: &Gear, after: &Gear) -> Vec { // A gun you've fired is still the gun you brought. let unloaded = |mut s: Stack| { if let Item::Weapon(w) = &mut s.item { w.loaded = 0; } s }; let mut had: Vec = everything(before).into_iter().map(unloaded).collect(); let mut found = Vec::new(); for stack in everything(after).into_iter().map(unloaded) { let mut count = stack.count; if let Some(old) = had.iter_mut().find(|s| s.item == stack.item) { let same = old.count.min(count); old.count -= same; count -= same; } if count > 0 { items::stow(&mut found, Stack { count, ..stack }); } } found } impl Scene for Derelict { fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { if self.ending.is_some() { return Transition::Pop; } if let Some(pause) = &mut self.pause { return match pause.key(key) { None => Transition::None, Some(choice) => { self.pause = None; match choice { Choice::Quit => Transition::Title, Choice::Here => { self.mode = Mode::Abandon; Transition::None } Choice::Resume => Transition::None, } } }; } if let Some(inv) = &mut self.inventory { let here: Vec = self.crawl.here().iter().map(|&i| self.crawl.loot[i].stack.clone()).collect(); let view = View { gear: &self.crawl.gear, beside: (!here.is_empty()).then_some((Beside::Floor("on the floor"), &here[..])), known: &self.crawl.known, hurt: true, shop: None, sort: ctx.inventory_sort, }; let action = match inv.key(key, &view) { Reply::Stay => return Transition::None, Reply::Close => { self.inventory = None; return Transition::None; } Reply::Sort(sort) => { ctx.inventory_sort = sort; return Transition::None; } Reply::Do(command) => match command { Command::Ready(i) => Action::Ready(i), Command::Wear(i) => Action::Wear(i), Command::TakeOff(slot) => Action::TakeOff(slot), Command::Eat(i) => Action::Eat(i), Command::UseMedpatch => Action::UseMedpatch, Command::Drop(spot, n) | Command::Stow(spot, n) => Action::Drop(spot, n), Command::Fetch(i, n) => Action::Take(i, n), // There's no one to trade with aboard a wreck. Command::Buy(_) | Command::Sell(_) | Command::SellAboard(_) | Command::Mend(_) | Command::MendAboard(_) | Command::MendAll => { return Transition::None; } }, }; let turns = self.crawl.turns; self.act(action, ctx); // Anything that took a turn closes it, so you see what happened. match &mut self.inventory { Some(_) if self.crawl.turns != turns || self.ending.is_some() => { self.inventory = None; } Some(inv) => inv.note = self.crawl.log.last().cloned(), None => {} } return Transition::None; } let action = match self.mode { Mode::Abandon => { self.mode = Mode::Explore; return match key.code { KeyCode::Char('y' | 'Y') => { ctx.record.abandoned += 1; ctx.hold.lose_carried(&self.crawl.gear); ctx.crawl = None; Transition::Pop } _ => Transition::None, }; } Mode::Explore => match keys::movement(key) { Some((dx, dy, true)) => { self.run(dx, dy, ctx); return Transition::None; } _ if matches!(key.code, KeyCode::Char('c' | 'C')) => { let gear = self.crawl.gear.clone(); return Transition::Push(Box::new(Sheet::aboard(&self.crawl.name, gear))); } _ => self.explore(key.code), }, Mode::Aim(at) => self.aim(key.code, at), }; if let Some(action) = action { self.act(action, 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, ctx.ticks); self.render_log(frame, log); self.render_panel(frame, panel); if let Some((title, lines)) = &self.ending { let mut lines: Vec = lines.iter().map(|l| Line::from(l.as_str())).collect(); lines.extend([Line::default(), keys::line(&[("any key", "carry on")])]); popup(frame, title, lines); return; } match self.mode { Mode::Abandon => popup( frame, " ABANDON SHIP? ", vec![ Line::from(format!("Leave {} by the emergency hatch?", self.crawl.name)), Line::from("Whatever you're carrying stays behind."), Line::default(), keys::line(&[("y", "abandon"), ("any other key", "keep exploring")]), ], ), Mode::Explore | Mode::Aim(_) => {} } if let Some(inv) = &self.inventory { let here: Vec = self.crawl.here().iter().map(|&i| self.crawl.loot[i].stack.clone()).collect(); let view = View { gear: &self.crawl.gear, beside: (!here.is_empty()).then_some((Beside::Floor("on the floor"), &here[..])), known: &self.crawl.known, hurt: true, shop: None, sort: ctx.inventory_sort, }; inv.render(frame, frame.area(), &view); } if let Some(pause) = &self.pause { pause.render(frame, frame.area()); } } } /// A round in flight, pointing the way it's going. Cells are about twice as /// tall as they're wide, so the slope on screen is double the slope in cells. fn shot_glyph(trace: &Trace) -> char { match trace.dmg { Dmg::Burn => return '•', Dmg::Shock => return '~', Dmg::Hymn => return '♪', _ => {} } let (Some(first), Some(last)) = (trace.cells.first(), trace.cells.last()) else { return '*' }; let (dx, dy) = (f64::from(last.x - first.x), f64::from(last.y - first.y) * 2.0); let angle = dy.atan2(dx).to_degrees().rem_euclid(180.0); match angle { a if !(22.5..157.5).contains(&a) => '-', a if a < 67.5 => '\\', a if a < 112.5 => '|', _ => '/', } } /// 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. pub(crate) 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)), } } /// A weapon's name in the colour of its damage, cut to `width`. Relics /// stand out. fn title(w: &Weapon, width: usize) -> Span<'static> { let name: String = w.name().chars().take(width).collect(); let style = Style::new().fg(rgb(w.dmg.rgb(), 1.0)); Span::styled(name, if w.named { style.bold() } else { style }) } /// Rounds left, as pips. fn magazine(gun: &Weapon) -> Span<'static> { let Some(g) = gun.gun else { return Span::from("") }; if gun.loaded == 0 { return "empty (r)".red(); } let pips = "▮".repeat(gun.loaded as usize) + &"▯".repeat((g.magazine - gun.loaded) as usize); Span::from(pips).white() } fn label(text: &'static str) -> Span<'static> { Span::from(text).dark_gray() } fn percent(p: f64) -> i32 { (p * 100.0).round() as i32 } fn distance2(a: Pos, b: Pos) -> i32 { (a.x - b.x).pow(2) + (a.y - b.y).pow(2) } 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)) } fn dim((r, g, b): Rgb, k: f64) -> Rgb { let scale = |c: u8| (f64::from(c) * k).round() as u8; (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)) } /// A monster's health, without the numbers: you can see how hurt it is, /// not count its hit points. fn mini_bar(hp: i32, max: i32, width: i32) -> Span<'static> { let hp = hp.clamp(0, max); let filled = ((hp * width + max - 1) / max).clamp(0, width); 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(bar, Style::new().fg(colour)) } fn listed(items: &[String]) -> String { match items { [] => String::new(), [one] => one.clone(), [rest @ .., last] => format!("{} and {last}", rest.join(", ")), } } fn popup(frame: &mut Frame, title: &str, lines: Vec) { let area = frame.area(); let widest = lines.iter().map(Line::width).max().unwrap_or(0) as u16; let width = (widest + 4).max(title.chars().count() as u16 + 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)); 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::dungeon::arms::{self, Source}; use crate::term::KeyboardCaps; use ratatui::crossterm::event::KeyCode as Key; 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', }) } fn shifted(scene: &mut Derelict, code: KeyCode, ctx: &mut Ctx) -> Transition { scene.key(KeyEvent::new(code, KeyModifiers::SHIFT), ctx) } #[test] fn running_goes_until_something_happens_but_not_out_the_airlock() { let mut ctx = ctx(); let mut scene = Derelict::board(3, &mut ctx); let start = scene.crawl.player; let toward = key_toward_airlock(&scene); let KeyCode::Char(c) = toward else { unreachable!() }; let away = keys::direction(toward).map(|(dx, dy)| (-dx, -dy)).unwrap(); let away = [ Key::Left, Key::Right, Key::Up, Key::Down, Key::Home, Key::PageUp, Key::End, Key::PageDown, ] .into_iter() .find(|&k| keys::direction(k) == Some(away)) .unwrap(); shifted(&mut scene, away, &mut ctx); let ran = scene.crawl.player.steps_to(start); assert!(ran >= 1, "it gets going"); assert_eq!(scene.crawl.turns, ran as u32, "a turn a step, straight on"); // And back: it stops beside the airlock rather than go through. for _ in 0..ran { shifted(&mut scene, KeyCode::Char(c.to_ascii_uppercase()), &mut ctx); if scene.crawl.player == start { break; } } assert_eq!(scene.crawl.player, start); assert!(scene.crawl.outcome.is_none(), "still aboard"); assert!(matches!(press(&mut scene, toward, &mut ctx), Transition::None)); assert!(scene.crawl.outcome.is_some(), "a step of your own takes you out"); } #[test] fn a_medpatch_is_a_and_the_sheet_is_c() { let mut ctx = ctx(); let mut scene = Derelict::board(3, &mut ctx); scene.crawl.gear.add(medpatches(1)); scene.crawl.hp -= 5; press(&mut scene, KeyCode::Char('a'), &mut ctx); assert_eq!(scene.crawl.gear.medpatches(), 0); assert!(matches!(press(&mut scene, KeyCode::Char('c'), &mut ctx), Transition::Push(_))); } fn medpatches(n: u32) -> Stack { Stack { item: Item::Medpatch, count: n } } fn salvage() -> Item { Item::Salvage(items::Salvage { title: "coil of wire".into(), value: 12, mass: 2000 }) } #[test] fn escaping_banks_what_you_carry() { let mut ctx = ctx(); let mut scene = Derelict::board(3, &mut ctx); scene.crawl.gear.add(Stack { item: salvage(), count: 2 }); let toward = key_toward_airlock(&scene); assert!(matches!(press(&mut scene, toward, &mut ctx), Transition::None)); assert_eq!(ctx.hold.gear.pack.iter().filter(|s| s.item == salvage()).count(), 1); let shown = screen(&scene, &ctx); assert!(shown.contains("SAFE ABOARD YOUR SHIP")); assert!(shown.contains("2 coils of wire") && shown.contains("24 ¤"), "{shown}"); assert!(matches!(press(&mut scene, KeyCode::Char(' '), &mut ctx), Transition::Pop)); } /// Esc, then the menu's second choice, then yes. fn abandon(scene: &mut Derelict, ctx: &mut Ctx) { press(scene, KeyCode::Esc, ctx); press(scene, KeyCode::Char('2'), ctx); press(scene, KeyCode::Char('y'), ctx); } #[test] fn abandoning_leaves_it_behind() { let mut ctx = ctx(); let mut scene = Derelict::board(3, &mut ctx); scene.crawl.gear.add(Stack::one(salvage())); press(&mut scene, KeyCode::Esc, &mut ctx); assert!(screen(&scene, &ctx).contains("PAUSED")); press(&mut scene, KeyCode::Char('2'), &mut ctx); assert!(screen(&scene, &ctx).contains("ABANDON SHIP?")); assert!(matches!(press(&mut scene, KeyCode::Char('y'), &mut ctx), Transition::Pop)); assert!(ctx.hold.gear.pack.iter().all(|s| s.item.is_kit())); assert!(ctx.crawl.is_none(), "nothing to come back to"); } #[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('q'), &mut ctx), Transition::None)); assert!(!screen(&scene, &ctx).contains("PAUSED")); press(&mut scene, KeyCode::Esc, &mut ctx); press(&mut scene, KeyCode::Char('2'), &mut ctx); assert!(matches!(press(&mut scene, KeyCode::Char('x'), &mut ctx), Transition::None)); assert!(!screen(&scene, &ctx).contains("ABANDON SHIP?")); } #[test] fn saving_and_quitting_brings_you_back_where_you_were() { let mut ctx = ctx(); let mut scene = Derelict::board(3, &mut ctx); scene.crawl.gear.add(Stack::one(salvage())); let key = key_toward_airlock(&scene); // A step away from the airlock and back is a turn or two in. let away = match key { KeyCode::Char('h') => KeyCode::Char('l'), KeyCode::Char('l') => KeyCode::Char('h'), KeyCode::Char('j') => KeyCode::Char('k'), _ => KeyCode::Char('j'), }; press(&mut scene, away, &mut ctx); press(&mut scene, KeyCode::Char('.'), &mut ctx); press(&mut scene, KeyCode::Esc, &mut ctx); assert!(matches!(press(&mut scene, KeyCode::Char('3'), &mut ctx), Transition::Title)); let saved = ctx.crawl.clone().expect("the wreck is kept"); assert_eq!(saved, scene.crawl.saved()); let back = Derelict::resumed(&ctx).unwrap(); let (was, is) = (&scene.crawl, &back.crawl); assert_eq!((is.player, is.hp, is.turns), (was.player, was.hp, was.turns)); assert_eq!(is.gear, was.gear); assert_eq!(is.loot, was.loot); assert_eq!(is.monsters.len(), was.monsters.len()); assert!(is.log.last().unwrap().contains("where you left off")); assert_eq!(back.brought, ctx.hold.gear, "what's found is what you didn't bring"); assert_eq!(ctx.record.boarded, 1, "coming back isn't boarding again"); let seen = |c: &Crawl| c.map.positions().filter(|&p| c.seen(p)).count(); assert_eq!(seen(is), seen(was), "what you'd seen you still have"); assert!(was.map.positions().all(|p| is.seen(p) == was.seen(p))); } #[test] fn medpatches_go_aboard_and_come_back_if_you_do() { let mut ctx = ctx(); ctx.hold.gear.add(medpatches(3)); ctx.hold.lockers.push(medpatches(4)); let mut scene = Derelict::board(3, &mut ctx); assert_eq!((scene.crawl.gear.medpatches(), ctx.hold.medpatches()), (3, 7)); let key = key_toward_airlock(&scene); press(&mut scene, key, &mut ctx); assert_eq!(scene.crawl.outcome, Some(Outcome::Escaped)); assert_eq!(ctx.hold.gear.medpatches(), 3); let mut scene = Derelict::board(3, &mut ctx); abandon(&mut scene, &mut ctx); assert_eq!(ctx.hold.gear.medpatches(), 0, "abandon ship and they stay behind"); assert_eq!(ctx.hold.medpatches(), 4, "but not what's in the lockers"); } #[test] fn weapons_go_aboard_and_come_back_if_you_do() { let mut ctx = ctx(); let mut cleaver = arms::find(&mut seed::rng(2), Source::Crew, false); cleaver.loaded = 0; ctx.hold.gear.add(Stack::one(Item::Weapon(cleaver.clone()))); let found = |g: &Gear| g.weapons().filter(|w| !w.kit).count(); let mut scene = Derelict::board(3, &mut ctx); assert_eq!((found(&scene.crawl.gear), found(&ctx.hold.gear)), (1, 1)); assert!(scene.crawl.gear.weapons().all(|w| w.loaded == w.gun.map_or(0, |g| g.magazine))); let key = key_toward_airlock(&scene); press(&mut scene, key, &mut ctx); assert_eq!(found(&ctx.hold.gear), 1); assert!(screen(&scene, &ctx).contains("nothing new"), "what you brought isn't found"); let mut scene = Derelict::board(3, &mut ctx); abandon(&mut scene, &mut ctx); assert_eq!(found(&ctx.hold.gear), 0, "abandon ship and it stays behind"); assert_eq!(ctx.hold.gear.weapons().count(), 2, "but not your own torch and rivet gun"); } #[test] fn aiming_and_the_inventory_open_and_close() { let mut ctx = ctx(); let mut scene = Derelict::new(3); press(&mut scene, KeyCode::Char('f'), &mut ctx); assert!(matches!(scene.mode, Mode::Aim(_))); assert!(screen(&scene, &ctx).contains("next target")); press(&mut scene, KeyCode::Esc, &mut ctx); assert!(matches!(scene.mode, Mode::Explore)); assert_eq!(scene.crawl.turns, 0, "lowering your gun takes no time"); press(&mut scene, KeyCode::Char('i'), &mut ctx); let shown = screen(&scene, &ctx); assert!(shown.contains("YOU ·") && shown.contains("cutting torch"), "{shown}"); press(&mut scene, KeyCode::Char('j'), &mut ctx); assert_eq!(scene.crawl.turns, 0, "looking takes no time"); press(&mut scene, KeyCode::Esc, &mut ctx); assert!(scene.inventory.is_none()); } #[test] fn using_things_from_the_inventory_takes_a_turn_and_closes_it() { let mut ctx = ctx(); let mut scene = Derelict::new(3); scene.crawl.gear.add(Stack::one(salvage())); scene.crawl.gear.add(medpatches(1)); scene.crawl.hp -= 5; press(&mut scene, KeyCode::Char('i'), &mut ctx); find(&mut scene, "coil", &mut ctx); press(&mut scene, KeyCode::Char('d'), &mut ctx); assert_eq!(scene.crawl.turns, 0, "dropping is free"); assert!(scene.inventory.is_some(), "and leaves it open"); assert_eq!(scene.crawl.here().len(), 1); find(&mut scene, "medpatch", &mut ctx); press(&mut scene, KeyCode::Char('a'), &mut ctx); assert_eq!(scene.crawl.turns, 1, "a medpatch takes a turn"); assert!(scene.inventory.is_none(), "and closes it"); } /// Picks out the first thing in the open inventory called `what`. fn find(scene: &mut Derelict, what: &str, ctx: &mut Ctx) { press(scene, KeyCode::Char('/'), ctx); press(scene, KeyCode::Esc, ctx); press(scene, KeyCode::Char('/'), ctx); for c in what.chars() { press(scene, KeyCode::Char(c), ctx); } press(scene, KeyCode::Enter, ctx); } #[test] fn firing_takes_a_turn_and_spends_a_round() { let mut ctx = ctx(); let mut scene = Derelict::new(3); press(&mut scene, KeyCode::Char('f'), &mut ctx); scene.mode = Mode::Aim(scene.crawl.player.offset(2, 0)); ctx.ticks = 100; press(&mut scene, KeyCode::Char('f'), &mut ctx); assert_eq!(scene.crawl.turns, 1); assert_eq!(scene.crawl.gear.gun.loaded, scene.crawl.gear.gun.gun.unwrap().magazine - 1); assert!(!scene.crawl.volley.is_empty()); assert_eq!(scene.volley_at, 100); assert!(scene.in_flight(100_000).is_empty(), "long since landed"); } #[test] fn your_record_counts_wrecks_kills_and_how_you_left_them() { let mut ctx = ctx(); let mut scene = Derelict::board(3, &mut ctx); let kind = scene.crawl.monsters[0].kind.name; scene.crawl.monsters[0].hp = 0; press(&mut scene, KeyCode::Char('.'), &mut ctx); assert_eq!(ctx.record.slain.get(kind), Some(&1)); let key = key_toward_airlock(&scene); press(&mut scene, key, &mut ctx); let mut scene = Derelict::board(3, &mut ctx); abandon(&mut scene, &mut ctx); ctx.hold.gear.add(medpatches(2)); let mut scene = Derelict::board(3, &mut ctx); scene.crawl.bleeding = 100; press(&mut scene, KeyCode::Char('.'), &mut ctx); assert!(matches!(scene.crawl.outcome, Some(Outcome::Died { .. }))); assert!(screen(&scene, &ctx).contains(&ctx.character.ship_name())); assert_eq!(ctx.hold.gear.medpatches(), 0, "brought down, and they stay behind"); let r = &ctx.record; assert_eq!((r.boarded, r.escaped, r.abandoned, r.downed), (3, 1, 1, 1)); } #[test] fn until_you_leave_the_wreck_your_hold_is_as_you_left_it() { let mut ctx = ctx(); ctx.hold.gear.add(medpatches(2)); let mut scene = Derelict::board(3, &mut ctx); scene.crawl.gear = Gear::default(); press(&mut scene, KeyCode::Char('.'), &mut ctx); assert_eq!(ctx.hold.gear.medpatches(), 2, "so a game saved now still has them"); } #[test] fn what_you_learn_you_keep() { let mut ctx = ctx(); let mut scene = Derelict::board(3, &mut ctx); scene.crawl.known.insert(("rust-mite", Dmg::Blunt)); press(&mut scene, KeyCode::Char('.'), &mut ctx); assert!(ctx.bestiary.contains(&("rust-mite", Dmg::Blunt))); let scene = Derelict::board(4, &mut ctx); assert!(scene.crawl.known.contains(&("rust-mite", Dmg::Blunt))); } }