summaryrefslogtreecommitdiff
path: root/src/dungeon/crawl.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/dungeon/crawl.rs')
-rw-r--r--src/dungeon/crawl.rs416
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);
+ }
+}