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/dungeon/crawl.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/dungeon/crawl.rs')
| -rw-r--r-- | src/dungeon/crawl.rs | 416 |
1 files changed, 416 insertions, 0 deletions
diff --git a/src/dungeon/crawl.rs b/src/dungeon/crawl.rs new file mode 100644 index 0000000..c5196b9 --- /dev/null +++ b/src/dungeon/crawl.rs @@ -0,0 +1,416 @@ +//! The rules of a crawl: moving, fighting, picking things up, and what the +//! derelict's inhabitants do each turn. Nothing here draws; the scene turns +//! keys into `Action`s and draws whatever state results. + +use rand::RngExt; + +use super::fov::{self, Fov}; +use super::ship; +use super::{Map, Pos, Tile}; +use crate::names::Artifact; +use crate::seed::{self, Rng}; + +/// How far your lamp reaches, in cells. +pub const SIGHT_RADIUS: i32 = 8; +const PLAYER_HP: i32 = 20; +/// Your cutting torch. +const PLAYER_DAMAGE: (i32, i32) = (2, 5); +const PLAYER_HIT: f64 = 0.8; +const MONSTER_HIT: f64 = 0.7; +const MEDPATCH_HEAL: i32 = 8; +const LOG_LENGTH: usize = 50; + +pub struct MonsterKind { + pub name: &'static str, + pub glyph: char, + pub rgb: (u8, u8, u8), + pub hp: i32, + pub damage: (i32, i32), + /// As in "the rust-mite bites you". + pub verb: &'static str, + /// How common it is, relative to the others. + pub weight: u32, + /// At most one aboard any ship. + pub unique: bool, +} + +#[rustfmt::skip] +pub const MONSTERS: &[MonsterKind] = &[ + MonsterKind { name: "rust-mite", glyph: 'm', rgb: (196, 112, 60), hp: 3, damage: (1, 2), verb: "bites", weight: 5, unique: false }, + MonsterKind { name: "maintenance drone", glyph: 'd', rgb: (150, 175, 200), hp: 6, damage: (1, 3), verb: "zaps", weight: 3, unique: false }, + MonsterKind { name: "hollow crewman", glyph: 'h', rgb: (160, 200, 150), hp: 9, damage: (2, 4), verb: "claws at", weight: 2, unique: false }, + MonsterKind { name: "choir-wraith", glyph: 'W', rgb: (200, 150, 255), hp: 14, damage: (3, 6), verb: "sings into", weight: 1, unique: true }, +]; + +pub struct Monster { + pub kind: &'static MonsterKind, + pub pos: Pos, + pub hp: i32, + /// Dormant until it comes into view; then it hunts you. + pub awake: bool, +} + +pub enum Item { + Artifact(Artifact), + Salvage { name: &'static str, value: u32 }, + Medpatch, +} + +pub struct Loot { + pub pos: Pos, + pub item: Item, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Outcome { + /// Back through the airlock, with everything carried. + Escaped, + Died { + killer: &'static str, + }, +} + +#[derive(Clone, Copy, Debug)] +pub enum Action { + Move(i32, i32), + Wait, + UseMedpatch, +} + +pub struct Crawl { + /// "the Vexant". + pub name: String, + /// "Cant hauler". + pub description: String, + pub map: Map, + pub player: Pos, + pub hp: i32, + pub max_hp: i32, + pub monsters: Vec<Monster>, + pub loot: Vec<Loot>, + /// What you're carrying. It's only yours once you're back through the airlock. + pub artifacts: Vec<Artifact>, + pub scrip: u32, + pub medpatches: u32, + pub fov: Fov, + /// Cells you've seen at some point, drawn from memory when out of sight. + seen: Vec<bool>, + /// Messages, oldest first. + pub log: Vec<String>, + pub turns: u32, + pub outcome: Option<Outcome>, + /// Dice for fights. Separate from the ship's seed, so the same ship and + /// the same moves always play out the same way. + rng: Rng, +} + +impl Crawl { + pub fn new(seed: u64) -> Crawl { + let ship = ship::generate(seed::derive(seed, "ship", 0)); + let cells = (ship.map.width * ship.map.height) as usize; + let mut crawl = Crawl { + fov: fov::compute(&ship.map, ship.start, SIGHT_RADIUS), + name: ship.name, + description: ship.description, + map: ship.map, + player: ship.start, + hp: PLAYER_HP, + max_hp: PLAYER_HP, + monsters: ship.monsters, + loot: ship.loot, + artifacts: Vec::new(), + scrip: 0, + medpatches: 0, + seen: vec![false; cells], + log: Vec::new(), + turns: 0, + outcome: None, + rng: seed::rng(seed::derive(seed, "dice", 0)), + }; + crawl.say(format!("You board {}, a derelict {}.", crawl.name, crawl.description)); + crawl.look(); + crawl + } + + pub fn seen(&self, p: Pos) -> bool { + self.map.contains(p) && self.seen[self.map.index(p)] + } + + pub fn monster_at(&self, p: Pos) -> Option<&Monster> { + self.monsters.iter().find(|m| m.pos == p) + } + + pub fn loot_at(&self, p: Pos) -> Option<&Loot> { + self.loot.iter().find(|l| l.pos == p) + } + + /// The name of where you're standing. + pub fn location(&self) -> &'static str { + self.map.room_at(self.player).map_or("a dark passage", |r| r.name) + } + + /// Takes your turn, then everything else's. Does nothing once the crawl + /// is over, or if the action wouldn't take any time (walking into a wall). + pub fn act(&mut self, action: Action) { + if self.outcome.is_some() { + return; + } + let took_time = match action { + Action::Wait => true, + Action::Move(dx, dy) => self.step(dx, dy), + Action::UseMedpatch => self.use_medpatch(), + }; + if !took_time || self.outcome.is_some() { + return; + } + self.turns += 1; + self.monsters_act(); + self.look(); + } + + fn say(&mut self, message: String) { + self.log.push(message); + if self.log.len() > LOG_LENGTH { + self.log.remove(0); + } + } + + /// Recomputes what you can see, remembers it, and wakes anything that's + /// now in view (and so, by symmetry, can see you). + fn look(&mut self) { + self.fov = fov::compute(&self.map, self.player, SIGHT_RADIUS); + for p in self.map.positions() { + if self.fov.contains(p) { + let i = self.map.index(p); + self.seen[i] = true; + } + } + let mut woken = Vec::new(); + for m in &mut self.monsters { + if !m.awake && self.fov.contains(m.pos) { + m.awake = true; + woken.push(m.kind.name); + } + } + for name in woken { + self.say(format!("A {name} stirs and turns toward you.")); + } + } + + fn step(&mut self, dx: i32, dy: i32) -> bool { + let target = self.player.offset(dx, dy); + if let Some(i) = self.monsters.iter().position(|m| m.pos == target) { + self.attack(i); + return true; + } + match self.map.tile(target) { + Tile::Door { open: false } => { + self.map.set(target, Tile::Door { open: true }); + self.say("The door grinds open.".into()); + true + } + Tile::Airlock => { + self.outcome = Some(Outcome::Escaped); + self.say("You cycle the airlock and cast off.".into()); + true + } + tile if tile.walkable() => { + self.player = target; + self.pick_up(); + true + } + _ => false, + } + } + + fn pick_up(&mut self) { + while let Some(i) = self.loot.iter().position(|l| l.pos == self.player) { + match self.loot.remove(i).item { + Item::Artifact(artifact) => { + self.say(format!("You find {}.", artifact.unidentified)); + self.artifacts.push(artifact); + } + Item::Salvage { name, value } => { + self.scrip += value; + self.say(format!("You salvage {name} ({value} ยค).")); + } + Item::Medpatch => { + self.medpatches += 1; + self.say("You pocket a medpatch.".into()); + } + } + } + } + + fn use_medpatch(&mut self) -> bool { + if self.medpatches == 0 { + self.say("You have no medpatches.".into()); + return false; + } + if self.hp == self.max_hp { + self.say("You're not hurt.".into()); + return false; + } + self.medpatches -= 1; + let healed = MEDPATCH_HEAL.min(self.max_hp - self.hp); + self.hp += healed; + self.say(format!("The medpatch stings, then numbs (+{healed}).")); + true + } + + fn attack(&mut self, i: usize) { + let name = self.monsters[i].kind.name; + self.monsters[i].awake = true; + if !self.rng.random_bool(PLAYER_HIT) { + self.say(format!("Your torch misses the {name}.")); + return; + } + let damage = self.rng.random_range(PLAYER_DAMAGE.0..=PLAYER_DAMAGE.1); + self.monsters[i].hp -= damage; + if self.monsters[i].hp <= 0 { + self.monsters.remove(i); + self.say(format!("You burn the {name} down to slag.")); + } else { + self.say(format!("You burn the {name} ({damage}).")); + } + } + + /// Each awake monster attacks if it's next to you, or else takes one + /// step along the shortest path toward you, opening doors on the way. + fn monsters_act(&mut self) { + let dist = self.map.distances_from(self.player); + for i in 0..self.monsters.len() { + if self.outcome.is_some() { + return; + } + let monster = &self.monsters[i]; + if !monster.awake { + continue; + } + if monster.pos.steps_to(self.player) == 1 { + self.monster_attacks(i); + continue; + } + let here = dist[self.map.index(monster.pos)].unwrap_or(u32::MAX); + let next = monster + .pos + .neighbours() + .filter(|&n| self.map.contains(n) && self.monster_at(n).is_none()) + .filter_map(|n| dist[self.map.index(n)].map(|d| (d, n))) + .filter(|&(d, _)| d < here) + .min_by_key(|&(d, n)| (d, n.y, n.x)); + if let Some((_, n)) = next { + if self.map.tile(n) == (Tile::Door { open: false }) { + self.map.set(n, Tile::Door { open: true }); + } else { + self.monsters[i].pos = n; + } + } + } + } + + fn monster_attacks(&mut self, i: usize) { + let kind = self.monsters[i].kind; + if !self.rng.random_bool(MONSTER_HIT) { + self.say(format!("The {} misses you.", kind.name)); + return; + } + let damage = self.rng.random_range(kind.damage.0..=kind.damage.1); + self.hp = (self.hp - damage).max(0); + self.say(format!("The {} {} you ({damage}).", kind.name, kind.verb)); + if self.hp == 0 { + self.outcome = Some(Outcome::Died { killer: kind.name }); + self.say(format!("You die aboard {}.", self.name)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A bot that wanders at random, for shaking out panics and broken rules. + fn wander(crawl: &mut Crawl, rng: &mut Rng, turns: usize) { + const ACTIONS: [Action; 10] = [ + Action::Move(-1, -1), + Action::Move(0, -1), + Action::Move(1, -1), + Action::Move(-1, 0), + Action::Move(1, 0), + Action::Move(-1, 1), + Action::Move(0, 1), + Action::Move(1, 1), + Action::Wait, + Action::UseMedpatch, + ]; + for _ in 0..turns { + crawl.act(ACTIONS[rng.random_range(..ACTIONS.len())]); + } + } + + #[test] + fn random_play_keeps_the_rules() { + for seed in 0..40 { + let mut crawl = Crawl::new(seed); + wander(&mut crawl, &mut seed::rng(seed), 600); + assert!(crawl.hp >= 0 && crawl.hp <= crawl.max_hp); + assert!(crawl.map.tile(crawl.player).walkable(), "seed {seed}: player in a wall"); + for m in &crawl.monsters { + assert!(crawl.map.tile(m.pos).walkable(), "seed {seed}: {} in a wall", m.kind.name); + assert_ne!(m.pos, crawl.player); + } + if crawl.outcome.is_none() { + assert!(crawl.fov.contains(crawl.player)); + } + } + } + + #[test] + fn same_seed_same_moves_same_story() { + let play = || { + let mut crawl = Crawl::new(7); + wander(&mut crawl, &mut seed::rng(70), 300); + crawl.log + }; + assert_eq!(play(), play()); + } + + #[test] + fn the_airlock_takes_you_home() { + let mut crawl = Crawl::new(3); + let airlock = crawl.player.neighbours().find(|&n| crawl.map.tile(n) == Tile::Airlock); + let airlock = airlock.expect("the start is beside the airlock"); + let (dx, dy) = (airlock.x - crawl.player.x, airlock.y - crawl.player.y); + crawl.act(Action::Move(dx, dy)); + assert_eq!(crawl.outcome, Some(Outcome::Escaped)); + } + + #[test] + fn fighting_kills_things() { + let mut crawl = Crawl::new(5); + let next_to_player = crawl.player.neighbours().find(|&n| crawl.map.tile(n).walkable()); + let pos = next_to_player.unwrap(); + crawl.monsters = vec![Monster { kind: &MONSTERS[0], pos, hp: 3, awake: true }]; + let (dx, dy) = (pos.x - crawl.player.x, pos.y - crawl.player.y); + for _ in 0..50 { + crawl.act(Action::Move(dx, dy)); + if crawl.monsters.is_empty() { + break; + } + } + assert!(crawl.monsters.is_empty(), "a rust-mite survived 50 swings"); + assert!(crawl.log.iter().any(|m| m.contains("slag"))); + } + + #[test] + fn walking_into_walls_takes_no_time() { + let mut crawl = Crawl::new(11); + let wall = crawl + .player + .neighbours() + .find(|&n| matches!(crawl.map.tile(n), Tile::Wall | Tile::Hull)); + let wall = wall.unwrap(); + crawl.act(Action::Move(wall.x - crawl.player.x, wall.y - crawl.player.y)); + assert_eq!(crawl.turns, 0); + } +} |