Browse Source

Chapter 21 - REX Paint menu.

Herbert Wolverson 4 years ago
parent
commit
f39e15ee1c

+ 2 - 0
.vscode/spellright.dict

@@ -20,3 +20,5 @@ serializer
 deserializer
 bitset
 Bitsets
+Gedminas
+Kyzrati

+ 11 - 0
Cargo.lock

@@ -304,6 +304,17 @@ dependencies = [
  "specs-derive 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
 ]
 
+[[package]]
+name = "chapter-21-rexmenu"
+version = "0.1.0"
+dependencies = [
+ "rltk 0.2.5 (git+https://github.com/thebracket/rltk_rs)",
+ "serde 1.0.99 (registry+https://github.com/rust-lang/crates.io-index)",
+ "serde_json 1.0.40 (registry+https://github.com/rust-lang/crates.io-index)",
+ "specs 0.15.0 (registry+https://github.com/rust-lang/crates.io-index)",
+ "specs-derive 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
+]
+
 [[package]]
 name = "cloudabi"
 version = "0.0.3"

+ 2 - 1
Cargo.toml

@@ -31,5 +31,6 @@ members = [
     "chapter-17-blood",
     "chapter-18-particles",
     "chapter-19-food",
-    "chapter-20-magicmapping"
+    "chapter-20-magicmapping",
+    "chapter-21-rexmenu"
     ]

+ 1 - 0
book/src/SUMMARY.md

@@ -22,5 +22,6 @@
     - [Particle Effects](./chapter_18.md)
     - [Hunger Clock](./chapter_19.md)
     - [Magic Mapping](./chapter_20.md)
+    - [REX Paint Menu](./chapter_21.md)
 
 [Contributors](./contributors.md)

BIN
book/src/c21-s1.jpg


BIN
book/src/c21-s2.jpg


BIN
book/src/c21-s3.jpg


BIN
book/src/c21-s4.jpg


+ 119 - 0
book/src/chapter_21.md

@@ -0,0 +1,119 @@
+# REX Paint Main Menu
+
+---
+
+***About this tutorial***
+
+*This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!*
+
+*If you enjoy this and would like me to keep writing, please consider supporting [my Patreon](https://www.patreon.com/blackfuture).*
+
+---
+
+Our main menu is really boring, and not a good way to attract players! This chapter will spice it up a bit.
+
+## REX Paint
+
+Grid Sage Games (the amazing u/Kyzrati on Reddit) provide a lovely tool for Codepage 437 image editing called [REX Paint](https://www.gridsagegames.com/rexpaint/index.html). RLTK has built-in support for using the output from this editor. As they used to say on the BBC's old kids show *Blue Peter* - here's one I made earlier.
+
+![Screenshot](./c21-s1.jpg)
+
+I cheated a bit; I found a CC0 image, resized it to 80x50 in the GIMP, and used a [tool I wrote years](https://github.com/thebracket/png2rex) ago to convert the PNG to a REX Paint file. Still, I like the result. You can find the REX Paint file in the `resources` folder.
+
+## Loading REX Assets
+
+We'll introduce a new file, `rex_assets.rs` to store our REX sprites. The file looks like this:
+
+```rust
+use rltk::{rex::XpFile};
+use std::fs::File;
+
+pub struct RexAssets {
+    pub menu : XpFile
+}
+
+impl RexAssets {
+    pub fn new() -> RexAssets {
+        RexAssets{
+            menu : XpFile::read(&mut File::open("../resources/SmallDungeon_80x50.xp").unwrap()).unwrap()
+        }
+    }
+}
+```
+
+Very simple - it defines a structure, and loads the dungeon graphic into it when `new` is called. We'll also insert it into Specs as a resource so we can access our sprites anywhere. In `main.rs`:
+
+```rust
+gs.ecs.insert(rex_assets::RexAssets::new());
+```
+
+Now we open up `gui.rs` and find the `main_menu` function. We'll add two lines before we start printing menu content:
+
+```rust
+let assets = gs.ecs.fetch::<RexAssets>();
+ctx.render_xp_sprite(&assets.menu, 0, 0);
+```
+
+The result (`cargo run` to see it) is a good start at a menu!
+
+![Screenshot](./c21-s2.jpg)
+
+## Improving the look of the menu - adding a box and borders
+
+To make it look a little snazzier, we'll work on spacing - and add a box for the menu and text. Replace the current title rendering code with:
+
+```rust
+ctx.draw_box_double(24, 18, 31, 10, RGB::named(rltk::WHEAT), RGB::named(rltk::BLACK));
+ctx.print_color_centered(20, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Rust Roguelike Tutorial");
+ctx.print_color_centered(21, RGB::named(rltk::CYAN), RGB::named(rltk::BLACK), "by Herbert Wolverson");
+ctx.print_color_centered(22, RGB::named(rltk::GRAY), RGB::named(rltk::BLACK), "Use Up/Down Arrows and Enter");
+```
+
+If you `cargo run` now, your menu looks like this:
+
+![Screenshot](./c21-s3.jpg)
+
+That's quite a bit better!
+
+## Fixing the spacing
+
+You'll notice that if you don't have a saved game to load, there is an annoying gap between menu items. This is an easy fix, by keeping track of the `y` position we have used while we render the menu. Here's the new menu rendering code:
+
+```rust
+let mut y = 24;
+    if let RunState::MainMenu{ menu_selection : selection } = *runstate {
+        if selection == MainMenuSelection::NewGame {
+            ctx.print_color_centered(y, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Begin New Game");
+        } else {
+            ctx.print_color_centered(y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "Begin New Game");
+        }
+        y += 1;
+
+        if save_exists {
+            if selection == MainMenuSelection::LoadGame {
+                ctx.print_color_centered(y, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Load Game");
+            } else {
+                ctx.print_color_centered(y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "Load Game");
+            }
+            y += 1;
+        }
+
+        if selection == MainMenuSelection::Quit {
+            ctx.print_color_centered(y, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Quit");
+        } else {
+            ctx.print_color_centered(y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "Quit");
+        }
+    ...
+```
+
+If you `cargo run` now, it looks better:
+
+![Screenshot](./c21-s4.jpg)
+
+**The source code for this chapter may be found [here](https://github.com/thebracket/rustrogueliketutorial/tree/master/chapter-21-rexmenu)**
+
+---
+
+Copyright (C) 2019, Herbert Wolverson.
+
+---

+ 14 - 0
chapter-21-rexmenu/Cargo.toml

@@ -0,0 +1,14 @@
+[package]
+name = "chapter-21-rexmenu"
+version = "0.1.0"
+authors = ["Herbert Wolverson <herberticus@gmail.com>"]
+edition = "2018"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+rltk = { git = "https://github.com/thebracket/rltk_rs", features = ["serialization"] }
+specs = { version = "0.15.0", features = ["serde"] }
+specs-derive = "0.4.0"
+serde= { version = "1.0", features = ["derive"] }
+serde_json = "1.0"

+ 380 - 0
chapter-21-rexmenu/src/components.rs

@@ -0,0 +1,380 @@
+extern crate specs;
+use specs::prelude::*;
+extern crate specs_derive;
+extern crate rltk;
+use rltk::{RGB};
+use serde::{Serialize, Deserialize};
+use specs::saveload::{Marker, ConvertSaveload};
+use specs::error::NoError;
+
+#[derive(Component, Serialize, Deserialize, Clone)]
+pub struct Position {
+    pub x: i32,
+    pub y: i32,
+}
+
+#[derive(Component, Serialize, Deserialize, Clone)]
+pub struct Renderable {
+    pub glyph: u8,
+    pub fg: RGB,
+    pub bg: RGB,
+    pub render_order : i32
+}
+ 
+#[derive(Component, Debug, Serialize, Deserialize, Clone)]
+pub struct Player {}
+
+#[derive(Component, Serialize, Deserialize, Clone)]
+pub struct Viewshed {
+    pub visible_tiles : Vec<rltk::Point>,
+    pub range : i32,
+    pub dirty : bool
+}
+
+#[derive(Component, Debug, Serialize, Deserialize, Clone)]
+pub struct Monster {}
+
+#[derive(Component, Debug, Serialize, Deserialize, Clone)]
+pub struct Name {
+    pub name : String
+}
+
+#[derive(Component, Debug, Serialize, Deserialize, Clone)]
+pub struct BlocksTile {}
+
+#[derive(Component, Debug, Serialize, Deserialize, Clone)]
+pub struct CombatStats {
+    pub max_hp : i32,
+    pub hp : i32,
+    pub defense : i32,
+    pub power : i32
+}
+
+// See wrapper below for serialization
+#[derive(Component, Debug)]
+pub struct WantsToMelee {
+    pub target : Entity
+}
+
+#[derive(Component, Debug, Serialize, Deserialize, Clone)]
+pub struct SufferDamage {
+    pub amount : i32
+}
+
+#[derive(Component, Debug, Serialize, Deserialize, Clone)]
+pub struct Item {}
+
+#[derive(Component, Debug, Serialize, Deserialize, Clone)]
+pub struct Consumable {}
+
+#[derive(Component, Debug, Serialize, Deserialize, Clone)]
+pub struct Ranged {
+    pub range : i32
+}
+
+#[derive(Component, Debug, Serialize, Deserialize, Clone)]
+pub struct InflictsDamage {
+    pub damage : i32
+}
+
+#[derive(Component, Debug, Serialize, Deserialize, Clone)]
+pub struct AreaOfEffect {
+    pub radius : i32
+}
+
+#[derive(Component, Debug, Serialize, Deserialize, Clone)]
+pub struct Confusion {
+    pub turns : i32
+}
+
+#[derive(Component, Debug, Serialize, Deserialize, Clone)]
+pub struct ProvidesHealing {
+    pub heal_amount : i32
+}
+
+// See wrapper below for serialization
+#[derive(Component, Debug)]
+pub struct InBackpack {
+    pub owner : Entity
+}
+
+// See wrapper below for serialization
+#[derive(Component, Debug)]
+pub struct WantsToPickupItem {
+    pub collected_by : Entity,
+    pub item : Entity
+}
+
+// See wrapper below for serialization
+#[derive(Component, Debug)]
+pub struct WantsToUseItem {
+    pub item : Entity,
+    pub target : Option<rltk::Point>
+}
+
+// See wrapper below for serialization
+#[derive(Component, Debug)]
+pub struct WantsToDropItem {
+    pub item : Entity
+}
+
+// See wrapper below for serialization
+#[derive(Component, Debug)]
+pub struct WantsToRemoveItem {
+    pub item : Entity
+}
+
+#[derive(PartialEq, Copy, Clone, Serialize, Deserialize)]
+pub enum EquipmentSlot { Melee, Shield }
+
+#[derive(Component, Serialize, Deserialize, Clone)]
+pub struct Equippable {
+    pub slot : EquipmentSlot
+}
+
+// See wrapper below for serialization
+#[derive(Component)]
+pub struct Equipped {
+    pub owner : Entity,
+    pub slot : EquipmentSlot
+}
+
+#[derive(Component, Serialize, Deserialize, Clone)]
+pub struct MeleePowerBonus {
+    pub power : i32
+}
+
+#[derive(Component, Serialize, Deserialize, Clone)]
+pub struct DefenseBonus {
+    pub defense : i32
+}
+
+#[derive(Component, Serialize, Deserialize, Clone)]
+pub struct ParticleLifetime {
+    pub lifetime_ms : f32
+}
+
+#[derive(Serialize, Deserialize, Copy, Clone, PartialEq)]
+pub enum HungerState { WellFed, Normal, Hungry, Starving }
+
+#[derive(Component, Serialize, Deserialize, Clone)]
+pub struct HungerClock {
+    pub state : HungerState,
+    pub duration : i32
+}
+
+#[derive(Component, Debug, Serialize, Deserialize, Clone)]
+pub struct ProvidesFood {}
+
+#[derive(Component, Debug, Serialize, Deserialize, Clone)]
+pub struct MagicMapper {}
+
+// Serialization helper code. We need to implement ConvertSaveLoad for each type that contains an
+// Entity.
+
+pub struct SerializeMe;
+
+// Special component that exists to help serialize the game data
+#[derive(Component, Serialize, Deserialize, Clone)]
+pub struct SerializationHelper {
+    pub map : super::map::Map
+}
+
+// WantsToMelee wrapper
+#[derive(Serialize, Deserialize, Clone)]
+pub struct WantsToMeleeData<M>(M);
+
+impl<M: Marker + Serialize> ConvertSaveload<M> for WantsToMelee
+where
+    for<'de> M: Deserialize<'de>,
+{
+    type Data = WantsToMeleeData<M>;
+    type Error = NoError;
+
+    fn convert_into<F>(&self, mut ids: F) -> Result<Self::Data, Self::Error>
+    where
+        F: FnMut(Entity) -> Option<M>,
+    {
+        let marker = ids(self.target).unwrap();
+        Ok(WantsToMeleeData(marker))
+    }
+
+    fn convert_from<F>(data: Self::Data, mut ids: F) -> Result<Self, Self::Error>
+    where
+        F: FnMut(M) -> Option<Entity>,
+    {
+        let entity = ids(data.0).unwrap();
+        Ok(WantsToMelee{target: entity})
+    }
+}
+
+// InBackpack wrapper
+#[derive(Serialize, Deserialize, Clone)]
+pub struct InBackpackData<M>(M);
+
+impl<M: Marker + Serialize> ConvertSaveload<M> for InBackpack
+where
+    for<'de> M: Deserialize<'de>,
+{
+    type Data = InBackpackData<M>;
+    type Error = NoError;
+
+    fn convert_into<F>(&self, mut ids: F) -> Result<Self::Data, Self::Error>
+    where
+        F: FnMut(Entity) -> Option<M>,
+    {
+        let marker = ids(self.owner).unwrap();
+        Ok(InBackpackData(marker))
+    }
+
+    fn convert_from<F>(data: Self::Data, mut ids: F) -> Result<Self, Self::Error>
+    where
+        F: FnMut(M) -> Option<Entity>,
+    {
+        let entity = ids(data.0).unwrap();
+        Ok(InBackpack{owner: entity})
+    }
+}
+
+// WantsToPickupItem wrapper
+#[derive(Serialize, Deserialize, Clone)]
+pub struct WantsToPickupItemData<M>(M, M);
+
+impl<M: Marker + Serialize> ConvertSaveload<M> for WantsToPickupItem
+where
+    for<'de> M: Deserialize<'de>,
+{
+    type Data = WantsToPickupItemData<M>;
+    type Error = NoError;
+
+    fn convert_into<F>(&self, mut ids: F) -> Result<Self::Data, Self::Error>
+    where
+        F: FnMut(Entity) -> Option<M>,
+    {
+        let marker = ids(self.collected_by).unwrap();
+        let marker2 = ids(self.item).unwrap();
+        Ok(WantsToPickupItemData(marker, marker2))
+    }
+
+    fn convert_from<F>(data: Self::Data, mut ids: F) -> Result<Self, Self::Error>
+    where
+        F: FnMut(M) -> Option<Entity>,
+    {
+        let collected_by = ids(data.0).unwrap();
+        let item = ids(data.1).unwrap();
+        Ok(WantsToPickupItem{collected_by, item})
+    }
+}
+
+// WantsToUseItem wrapper
+#[derive(Serialize, Deserialize, Clone)]
+pub struct WantsToUseItemData<M>(M, Option<rltk::Point>);
+
+impl<M: Marker + Serialize> ConvertSaveload<M> for WantsToUseItem
+where
+    for<'de> M: Deserialize<'de>,
+{
+    type Data = WantsToUseItemData<M>;
+    type Error = NoError;
+
+    fn convert_into<F>(&self, mut ids: F) -> Result<Self::Data, Self::Error>
+    where
+        F: FnMut(Entity) -> Option<M>,
+    {
+        let marker = ids(self.item).unwrap();
+        Ok(WantsToUseItemData(marker, self.target))
+    }
+
+    fn convert_from<F>(data: Self::Data, mut ids: F) -> Result<Self, Self::Error>
+    where
+        F: FnMut(M) -> Option<Entity>,
+    {
+        let item = ids(data.0).unwrap();
+        let target = data.1;
+        Ok(WantsToUseItem{item, target})
+    }
+}
+
+// WantsToDropItem wrapper
+#[derive(Serialize, Deserialize, Clone)]
+pub struct WantsToDropItemData<M>(M);
+
+impl<M: Marker + Serialize> ConvertSaveload<M> for WantsToDropItem
+where
+    for<'de> M: Deserialize<'de>,
+{
+    type Data = WantsToDropItemData<M>;
+    type Error = NoError;
+
+    fn convert_into<F>(&self, mut ids: F) -> Result<Self::Data, Self::Error>
+    where
+        F: FnMut(Entity) -> Option<M>,
+    {
+        let marker = ids(self.item).unwrap();
+        Ok(WantsToDropItemData(marker))
+    }
+
+    fn convert_from<F>(data: Self::Data, mut ids: F) -> Result<Self, Self::Error>
+    where
+        F: FnMut(M) -> Option<Entity>,
+    {
+        let entity = ids(data.0).unwrap();
+        Ok(WantsToDropItem{item: entity})
+    }
+}
+
+// WantsToRemoveItem wrapper
+#[derive(Serialize, Deserialize, Clone)]
+pub struct WantsToRemoveItemData<M>(M);
+
+impl<M: Marker + Serialize> ConvertSaveload<M> for WantsToRemoveItem
+where
+    for<'de> M: Deserialize<'de>,
+{
+    type Data = WantsToRemoveItemData<M>;
+    type Error = NoError;
+
+    fn convert_into<F>(&self, mut ids: F) -> Result<Self::Data, Self::Error>
+    where
+        F: FnMut(Entity) -> Option<M>,
+    {
+        let marker = ids(self.item).unwrap();
+        Ok(WantsToRemoveItemData(marker))
+    }
+
+    fn convert_from<F>(data: Self::Data, mut ids: F) -> Result<Self, Self::Error>
+    where
+        F: FnMut(M) -> Option<Entity>,
+    {
+        let entity = ids(data.0).unwrap();
+        Ok(WantsToRemoveItem{item: entity})
+    }
+}
+
+// Equipped wrapper
+#[derive(Serialize, Deserialize, Clone)]
+pub struct EquippedData<M>(M, EquipmentSlot);
+
+impl<M: Marker + Serialize> ConvertSaveload<M> for Equipped
+where
+    for<'de> M: Deserialize<'de>,
+{
+    type Data = EquippedData<M>;
+    type Error = NoError;
+
+    fn convert_into<F>(&self, mut ids: F) -> Result<Self::Data, Self::Error>
+    where
+        F: FnMut(Entity) -> Option<M>,
+    {
+        let marker = ids(self.owner).unwrap();
+        Ok(EquippedData(marker, self.slot))
+    }
+
+    fn convert_from<F>(data: Self::Data, mut ids: F) -> Result<Self, Self::Error>
+    where
+        F: FnMut(M) -> Option<Entity>,
+    {
+        let entity = ids(data.0).unwrap();
+        Ok(Equipped{owner: entity, slot : data.1})
+    }
+}

+ 62 - 0
chapter-21-rexmenu/src/damage_system.rs

@@ -0,0 +1,62 @@
+extern crate specs;
+use specs::prelude::*;
+use super::{CombatStats, SufferDamage, Player, Name, gamelog::GameLog, RunState, Position, Map};
+
+pub struct DamageSystem {}
+
+impl<'a> System<'a> for DamageSystem {
+    type SystemData = ( WriteStorage<'a, CombatStats>,
+                        WriteStorage<'a, SufferDamage>,
+                        ReadStorage<'a, Position>,
+                        WriteExpect<'a, Map>,
+                        Entities<'a> );
+
+    fn run(&mut self, data : Self::SystemData) {
+        let (mut stats, mut damage, positions, mut map, entities) = data;
+
+        for (entity, mut stats, damage) in (&entities, &mut stats, &damage).join() {
+            stats.hp -= damage.amount;
+            let pos = positions.get(entity);
+            if let Some(pos) = pos {
+                let idx = map.xy_idx(pos.x, pos.y);
+                map.bloodstains.insert(idx);
+            }
+        }
+
+        damage.clear();
+    }
+}
+
+pub fn delete_the_dead(ecs : &mut World) {
+    let mut dead : Vec<Entity> = Vec::new();
+    // Using a scope to make the borrow checker happy
+    {
+        let combat_stats = ecs.read_storage::<CombatStats>();
+        let players = ecs.read_storage::<Player>();
+        let names = ecs.read_storage::<Name>();
+        let entities = ecs.entities();
+        let mut log = ecs.write_resource::<GameLog>();
+        for (entity, stats) in (&entities, &combat_stats).join() {
+            if stats.hp < 1 { 
+                let player = players.get(entity);
+                match player {
+                    None => {
+                        let victim_name = names.get(entity);
+                        if let Some(victim_name) = victim_name {
+                            log.entries.insert(0, format!("{} is dead", &victim_name.name));
+                        }
+                        dead.push(entity)
+                    }
+                    Some(_) => {
+                        let mut runstate = ecs.write_resource::<RunState>();
+                        *runstate = RunState::GameOver;
+                    }
+                }
+            }
+        }
+    }
+
+    for victim in dead {
+        ecs.delete_entity(victim).expect("Unable to delete");
+    }    
+}

+ 3 - 0
chapter-21-rexmenu/src/gamelog.rs

@@ -0,0 +1,3 @@
+pub struct GameLog {
+    pub entries : Vec<String>
+}

+ 369 - 0
chapter-21-rexmenu/src/gui.rs

@@ -0,0 +1,369 @@
+extern crate rltk;
+use rltk::{ RGB, Rltk, Console, Point, VirtualKeyCode };
+extern crate specs;
+use specs::prelude::*;
+use super::{CombatStats, Player, gamelog::GameLog, Map, Name, Position, State, InBackpack, 
+    Viewshed, RunState, Equipped, HungerClock, HungerState, rex_assets::RexAssets};
+
+pub fn draw_ui(ecs: &World, ctx : &mut Rltk) {
+    ctx.draw_box(0, 43, 79, 6, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK));
+
+    let combat_stats = ecs.read_storage::<CombatStats>();
+    let players = ecs.read_storage::<Player>();
+    let hunger = ecs.read_storage::<HungerClock>();
+    for (_player, stats, hc) in (&players, &combat_stats, &hunger).join() {
+        let health = format!(" HP: {} / {} ", stats.hp, stats.max_hp);
+        ctx.print_color(12, 43, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), &health);
+
+        ctx.draw_bar_horizontal(28, 43, 51, stats.hp, stats.max_hp, RGB::named(rltk::RED), RGB::named(rltk::BLACK));
+
+        match hc.state {
+            HungerState::WellFed => ctx.print_color(71, 42, RGB::named(rltk::GREEN), RGB::named(rltk::BLACK), "Well Fed"),
+            HungerState::Normal => {}
+            HungerState::Hungry => ctx.print_color(71, 42, RGB::named(rltk::ORANGE), RGB::named(rltk::BLACK), "Hungry"),
+            HungerState::Starving => ctx.print_color(71, 42, RGB::named(rltk::RED), RGB::named(rltk::BLACK), "Starving"),
+        }
+    }
+
+    let map = ecs.fetch::<Map>();
+    let depth = format!("Depth: {}", map.depth);
+    ctx.print_color(2, 43, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), &depth);
+
+
+    let log = ecs.fetch::<GameLog>();
+    let mut y = 44;
+    for s in log.entries.iter() {
+        if y < 49 { ctx.print(2, y, &s.to_string()); }
+        y += 1;
+    }
+
+    // Draw mouse cursor
+    let mouse_pos = ctx.mouse_pos();
+    ctx.set_bg(mouse_pos.0, mouse_pos.1, RGB::named(rltk::MAGENTA));
+    draw_tooltips(ecs, ctx);
+}
+
+fn draw_tooltips(ecs: &World, ctx : &mut Rltk) {
+    let map = ecs.fetch::<Map>();
+    let names = ecs.read_storage::<Name>();
+    let positions = ecs.read_storage::<Position>();
+
+    let mouse_pos = ctx.mouse_pos();
+    if mouse_pos.0 >= map.width || mouse_pos.1 >= map.height { return; }
+    let mut tooltip : Vec<String> = Vec::new();
+    for (name, position) in (&names, &positions).join() {
+        if position.x == mouse_pos.0 && position.y == mouse_pos.1 {
+            tooltip.push(name.name.to_string());
+        }
+    }
+
+    if !tooltip.is_empty() {
+        let mut width :i32 = 0;
+        for s in tooltip.iter() {
+            if width < s.len() as i32 { width = s.len() as i32; }
+        }
+        width += 3;
+
+        if mouse_pos.0 > 40 {
+            let arrow_pos = Point::new(mouse_pos.0 - 2, mouse_pos.1);
+            let left_x = mouse_pos.0 - width;
+            let mut y = mouse_pos.1;
+            for s in tooltip.iter() {
+                ctx.print_color(left_x, y, RGB::named(rltk::WHITE), RGB::named(rltk::GREY), &s.to_string());
+                let padding = (width - s.len() as i32)-1;
+                for i in 0..padding {
+                    ctx.print_color(arrow_pos.x - i, y, RGB::named(rltk::WHITE), RGB::named(rltk::GREY), &" ".to_string());
+                }
+                y += 1;
+            }
+            ctx.print_color(arrow_pos.x, arrow_pos.y, RGB::named(rltk::WHITE), RGB::named(rltk::GREY), &"->".to_string());
+        } else {
+            let arrow_pos = Point::new(mouse_pos.0 + 1, mouse_pos.1);
+            let left_x = mouse_pos.0 +3;
+            let mut y = mouse_pos.1;
+            for s in tooltip.iter() {
+                ctx.print_color(left_x, y, RGB::named(rltk::WHITE), RGB::named(rltk::GREY), &s.to_string());
+                let padding = (width - s.len() as i32)-1;
+                for i in 0..padding {
+                    ctx.print_color(left_x + s.len() as i32 + i, y, RGB::named(rltk::WHITE), RGB::named(rltk::GREY), &" ".to_string());
+                }
+                y += 1;
+            }
+            ctx.print_color(arrow_pos.x, arrow_pos.y, RGB::named(rltk::WHITE), RGB::named(rltk::GREY), &"<-".to_string());
+        }
+    }
+}
+
+#[derive(PartialEq, Copy, Clone)]
+pub enum ItemMenuResult { Cancel, NoResponse, Selected }
+
+pub fn show_inventory(gs : &mut State, ctx : &mut Rltk) -> (ItemMenuResult, Option<Entity>) {
+    let player_entity = gs.ecs.fetch::<Entity>();
+    let names = gs.ecs.read_storage::<Name>();
+    let backpack = gs.ecs.read_storage::<InBackpack>();
+    let entities = gs.ecs.entities();
+
+    let inventory = (&backpack, &names).join().filter(|item| item.0.owner == *player_entity );
+    let count = inventory.count();
+
+    let mut y = (25 - (count / 2)) as i32;
+    ctx.draw_box(15, y-2, 31, (count+3) as i32, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK));
+    ctx.print_color(18, y-2, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Inventory");
+    ctx.print_color(18, y+count as i32+1, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "ESCAPE to cancel");
+
+    let mut equippable : Vec<Entity> = Vec::new();
+    let mut j = 0;
+    for (entity, _pack, name) in (&entities, &backpack, &names).join().filter(|item| item.1.owner == *player_entity ) {
+        ctx.set(17, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437('('));
+        ctx.set(18, y, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), 97+j as u8);
+        ctx.set(19, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437(')'));
+
+        ctx.print(21, y, &name.name.to_string());
+        equippable.push(entity);
+        y += 1;
+        j += 1;
+    }
+
+    match ctx.key {
+        None => (ItemMenuResult::NoResponse, None),
+        Some(key) => {
+            match key {
+                VirtualKeyCode::Escape => { (ItemMenuResult::Cancel, None) }
+                _ => { 
+                    let selection = rltk::letter_to_option(key);
+                    if selection > -1 && selection < count as i32 {
+                        return (ItemMenuResult::Selected, Some(equippable[selection as usize]));
+                    }  
+                    (ItemMenuResult::NoResponse, None)
+                }
+            }
+        }
+    }
+}
+
+pub fn drop_item_menu(gs : &mut State, ctx : &mut Rltk) -> (ItemMenuResult, Option<Entity>) {
+    let player_entity = gs.ecs.fetch::<Entity>();
+    let names = gs.ecs.read_storage::<Name>();
+    let backpack = gs.ecs.read_storage::<InBackpack>();
+    let entities = gs.ecs.entities();
+
+    let inventory = (&backpack, &names).join().filter(|item| item.0.owner == *player_entity );
+    let count = inventory.count();
+
+    let mut y = (25 - (count / 2)) as i32;
+    ctx.draw_box(15, y-2, 31, (count+3) as i32, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK));
+    ctx.print_color(18, y-2, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Drop Which Item?");
+    ctx.print_color(18, y+count as i32+1, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "ESCAPE to cancel");
+
+    let mut equippable : Vec<Entity> = Vec::new();
+    let mut j = 0;
+    for (entity, _pack, name) in (&entities, &backpack, &names).join().filter(|item| item.1.owner == *player_entity ) {
+        ctx.set(17, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437('('));
+        ctx.set(18, y, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), 97+j as u8);
+        ctx.set(19, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437(')'));
+
+        ctx.print(21, y, &name.name.to_string());
+        equippable.push(entity);
+        y += 1;
+        j += 1;
+    }
+
+    match ctx.key {
+        None => (ItemMenuResult::NoResponse, None),
+        Some(key) => {
+            match key {
+                VirtualKeyCode::Escape => { (ItemMenuResult::Cancel, None) }
+                _ => { 
+                    let selection = rltk::letter_to_option(key);
+                    if selection > -1 && selection < count as i32 {
+                        return (ItemMenuResult::Selected, Some(equippable[selection as usize]));
+                    }  
+                    (ItemMenuResult::NoResponse, None)
+                }
+            }
+        }
+    }
+}
+
+pub fn remove_item_menu(gs : &mut State, ctx : &mut Rltk) -> (ItemMenuResult, Option<Entity>) {
+    let player_entity = gs.ecs.fetch::<Entity>();
+    let names = gs.ecs.read_storage::<Name>();
+    let backpack = gs.ecs.read_storage::<Equipped>();
+    let entities = gs.ecs.entities();
+
+    let inventory = (&backpack, &names).join().filter(|item| item.0.owner == *player_entity );
+    let count = inventory.count();
+
+    let mut y = (25 - (count / 2)) as i32;
+    ctx.draw_box(15, y-2, 31, (count+3) as i32, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK));
+    ctx.print_color(18, y-2, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Remove Which Item?");
+    ctx.print_color(18, y+count as i32+1, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "ESCAPE to cancel");
+
+    let mut equippable : Vec<Entity> = Vec::new();
+    let mut j = 0;
+    for (entity, _pack, name) in (&entities, &backpack, &names).join().filter(|item| item.1.owner == *player_entity ) {
+        ctx.set(17, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437('('));
+        ctx.set(18, y, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), 97+j as u8);
+        ctx.set(19, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437(')'));
+
+        ctx.print(21, y, &name.name.to_string());
+        equippable.push(entity);
+        y += 1;
+        j += 1;
+    }
+
+    match ctx.key {
+        None => (ItemMenuResult::NoResponse, None),
+        Some(key) => {
+            match key {
+                VirtualKeyCode::Escape => { (ItemMenuResult::Cancel, None) }
+                _ => { 
+                    let selection = rltk::letter_to_option(key);
+                    if selection > -1 && selection < count as i32 {
+                        return (ItemMenuResult::Selected, Some(equippable[selection as usize]));
+                    }  
+                    (ItemMenuResult::NoResponse, None)
+                }
+            }
+        }
+    }
+}
+
+pub fn ranged_target(gs : &mut State, ctx : &mut Rltk, range : i32) -> (ItemMenuResult, Option<Point>) {
+    let player_entity = gs.ecs.fetch::<Entity>();
+    let player_pos = gs.ecs.fetch::<Point>();
+    let viewsheds = gs.ecs.read_storage::<Viewshed>();
+
+    ctx.print_color(5, 0, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Select Target:");
+
+    // Highlight available target cells
+    let mut available_cells = Vec::new();
+    let visible = viewsheds.get(*player_entity);
+    if let Some(visible) = visible {
+        // We have a viewshed
+        for idx in visible.visible_tiles.iter() {
+            let distance = rltk::DistanceAlg::Pythagoras.distance2d(*player_pos, *idx);
+            if distance <= range as f32 {
+                ctx.set_bg(idx.x, idx.y, RGB::named(rltk::BLUE));
+                available_cells.push(idx);
+            }
+        }
+    } else {
+        return (ItemMenuResult::Cancel, None);
+    }
+
+    // Draw mouse cursor
+    let mouse_pos = ctx.mouse_pos();
+    let mut valid_target = false;
+    for idx in available_cells.iter() { if idx.x == mouse_pos.0 && idx.y == mouse_pos.1 { valid_target = true; } }
+    if valid_target {
+        ctx.set_bg(mouse_pos.0, mouse_pos.1, RGB::named(rltk::CYAN));
+        if ctx.left_click {
+            return (ItemMenuResult::Selected, Some(Point::new(mouse_pos.0, mouse_pos.1)));
+        }
+    } else {
+        ctx.set_bg(mouse_pos.0, mouse_pos.1, RGB::named(rltk::RED));
+        if ctx.left_click {
+            return (ItemMenuResult::Cancel, None);
+        }
+    }
+
+    (ItemMenuResult::NoResponse, None)
+}
+
+#[derive(PartialEq, Copy, Clone)]
+pub enum MainMenuSelection { NewGame, LoadGame, Quit }
+
+#[derive(PartialEq, Copy, Clone)]
+pub enum MainMenuResult { NoSelection{ selected : MainMenuSelection }, Selected{ selected: MainMenuSelection } }
+
+pub fn main_menu(gs : &mut State, ctx : &mut Rltk) -> MainMenuResult {
+    let save_exists = super::saveload_system::does_save_exist();
+    let runstate = gs.ecs.fetch::<RunState>();
+    let assets = gs.ecs.fetch::<RexAssets>();
+    ctx.render_xp_sprite(&assets.menu, 0, 0);
+
+    ctx.draw_box_double(24, 18, 31, 10, RGB::named(rltk::WHEAT), RGB::named(rltk::BLACK));
+
+    ctx.print_color_centered(20, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Rust Roguelike Tutorial");
+    ctx.print_color_centered(21, RGB::named(rltk::CYAN), RGB::named(rltk::BLACK), "by Herbert Wolverson");
+    ctx.print_color_centered(22, RGB::named(rltk::GRAY), RGB::named(rltk::BLACK), "Use Up/Down Arrows and Enter");
+    
+    let mut y = 24;
+    if let RunState::MainMenu{ menu_selection : selection } = *runstate {
+        if selection == MainMenuSelection::NewGame {
+            ctx.print_color_centered(y, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Begin New Game");
+        } else {
+            ctx.print_color_centered(y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "Begin New Game");
+        }
+        y += 1;
+
+        if save_exists {
+            if selection == MainMenuSelection::LoadGame {
+                ctx.print_color_centered(y, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Load Game");
+            } else {
+                ctx.print_color_centered(y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "Load Game");
+            }
+            y += 1;
+        }
+
+        if selection == MainMenuSelection::Quit {
+            ctx.print_color_centered(y, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Quit");
+        } else {
+            ctx.print_color_centered(y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "Quit");
+        }
+
+        match ctx.key {
+            None => return MainMenuResult::NoSelection{ selected: selection },
+            Some(key) => {
+                match key {
+                    VirtualKeyCode::Escape => { return MainMenuResult::NoSelection{ selected: MainMenuSelection::Quit } }
+                    VirtualKeyCode::Up => {
+                        let mut newselection;
+                        match selection {
+                            MainMenuSelection::NewGame => newselection = MainMenuSelection::Quit,
+                            MainMenuSelection::LoadGame => newselection = MainMenuSelection::NewGame,
+                            MainMenuSelection::Quit => newselection = MainMenuSelection::LoadGame
+                        }
+                        if newselection == MainMenuSelection::LoadGame && !save_exists {
+                            newselection = MainMenuSelection::NewGame;
+                        }
+                        return MainMenuResult::NoSelection{ selected: newselection }
+                    }
+                    VirtualKeyCode::Down => {
+                        let mut newselection;
+                        match selection {
+                            MainMenuSelection::NewGame => newselection = MainMenuSelection::LoadGame,
+                            MainMenuSelection::LoadGame => newselection = MainMenuSelection::Quit,
+                            MainMenuSelection::Quit => newselection = MainMenuSelection::NewGame
+                        }
+                        if newselection == MainMenuSelection::LoadGame && !save_exists {
+                            newselection = MainMenuSelection::Quit;
+                        }
+                        return MainMenuResult::NoSelection{ selected: newselection }
+                    }
+                    VirtualKeyCode::Return => return MainMenuResult::Selected{ selected : selection },
+                    _ => return MainMenuResult::NoSelection{ selected: selection }
+                }
+            }
+        }
+    }
+
+    MainMenuResult::NoSelection { selected: MainMenuSelection::NewGame }
+}
+
+#[derive(PartialEq, Copy, Clone)]
+pub enum GameOverResult { NoSelection, QuitToMenu }
+
+pub fn game_over(ctx : &mut Rltk) -> GameOverResult {
+    ctx.print_color_centered(15, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Your journey has ended!");
+    ctx.print_color_centered(17, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "One day, we'll tell you all about how you did.");
+    ctx.print_color_centered(18, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "That day, sadly, is not in this chapter..");
+
+    ctx.print_color_centered(20, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Press any key to return to the menu.");
+
+    match ctx.key {
+        None => GameOverResult::NoSelection,
+        Some(_) => GameOverResult::QuitToMenu
+    }
+}

+ 74 - 0
chapter-21-rexmenu/src/hunger_system.rs

@@ -0,0 +1,74 @@
+use specs::prelude::*;
+use super::{HungerClock, RunState, HungerState, SufferDamage, gamelog::GameLog};
+
+pub struct HungerSystem {}
+
+impl<'a> System<'a> for HungerSystem {
+    #[allow(clippy::type_complexity)]
+    type SystemData = ( 
+                        Entities<'a>,
+                        WriteStorage<'a, HungerClock>,
+                        ReadExpect<'a, Entity>, // The player
+                        ReadExpect<'a, RunState>,
+                        WriteStorage<'a, SufferDamage>,
+                        WriteExpect<'a, GameLog>
+                      );
+
+    fn run(&mut self, data : Self::SystemData) {
+        let (entities, mut hunger_clock, player_entity, runstate, mut inflict_damage, mut log) = data;
+
+        for (entity, mut clock) in (&entities, &mut hunger_clock).join() {
+            let mut proceed = false;
+
+            match *runstate {
+                RunState::PlayerTurn => {
+                    if entity == *player_entity {
+                        proceed = true;
+                    }
+                }
+                RunState::MonsterTurn => {
+                    if entity != *player_entity {
+                        proceed = false;
+                    }
+                }
+                _ => proceed = false
+            }
+
+            if proceed {
+                clock.duration -= 1;
+                if clock.duration < 1 {
+                    match clock.state {
+                        HungerState::WellFed => {
+                            clock.state = HungerState::Normal;
+                            clock.duration = 200;
+                            if entity == *player_entity {
+                                log.entries.insert(0, "You are no longer well fed.".to_string());
+                            }
+                        }
+                        HungerState::Normal => {
+                            clock.state = HungerState::Hungry;
+                            clock.duration = 200;
+                            if entity == *player_entity {
+                                log.entries.insert(0, "You are hungry.".to_string());
+                            }
+                        }
+                        HungerState::Hungry => {
+                            clock.state = HungerState::Starving;
+                            clock.duration = 200;
+                            if entity == *player_entity {
+                                log.entries.insert(0, "You are starving!".to_string());
+                            }
+                        }
+                        HungerState::Starving => {
+                            // Inflict damage from hunger
+                            if entity == *player_entity {
+                                log.entries.insert(0, "Your hunger pangs are getting painful! You suffer 1 hp damage.".to_string());
+                            }
+                            inflict_damage.insert(entity, SufferDamage{ amount: 1 }).expect("Unable to do damage");  
+                        }
+                    }
+                }
+            }
+        }
+    }
+}

+ 310 - 0
chapter-21-rexmenu/src/inventory_system.rs

@@ -0,0 +1,310 @@
+extern crate specs;
+use specs::prelude::*;
+use super::{WantsToPickupItem, Name, InBackpack, Position, gamelog::GameLog, WantsToUseItem, 
+    Consumable, ProvidesHealing, CombatStats, WantsToDropItem, InflictsDamage, Map, SufferDamage,
+    AreaOfEffect, Confusion, Equippable, Equipped, WantsToRemoveItem, particle_system::ParticleBuilder,
+    ProvidesFood, HungerClock, HungerState, MagicMapper, RunState};
+
+pub struct ItemCollectionSystem {}
+
+impl<'a> System<'a> for ItemCollectionSystem {
+    #[allow(clippy::type_complexity)]
+    type SystemData = ( ReadExpect<'a, Entity>,
+                        WriteExpect<'a, GameLog>,
+                        WriteStorage<'a, WantsToPickupItem>,
+                        WriteStorage<'a, Position>,
+                        ReadStorage<'a, Name>,
+                        WriteStorage<'a, InBackpack>
+                      );
+
+    fn run(&mut self, data : Self::SystemData) {
+        let (player_entity, mut gamelog, mut wants_pickup, mut positions, names, mut backpack) = data;
+
+        for pickup in wants_pickup.join() {
+            positions.remove(pickup.item);
+            backpack.insert(pickup.item, InBackpack{ owner: pickup.collected_by }).expect("Unable to insert backpack entry");
+
+            if pickup.collected_by == *player_entity {
+                gamelog.entries.insert(0, format!("You pick up the {}.", names.get(pickup.item).unwrap().name));
+            }
+        }
+
+        wants_pickup.clear();
+    }
+}
+
+pub struct ItemUseSystem {}
+
+impl<'a> System<'a> for ItemUseSystem {
+    #[allow(clippy::type_complexity)]
+    type SystemData = ( ReadExpect<'a, Entity>,
+                        WriteExpect<'a, GameLog>,
+                        WriteExpect<'a, Map>,
+                        Entities<'a>,
+                        WriteStorage<'a, WantsToUseItem>,
+                        ReadStorage<'a, Name>,
+                        ReadStorage<'a, Consumable>,
+                        ReadStorage<'a, ProvidesHealing>,
+                        ReadStorage<'a, InflictsDamage>,
+                        WriteStorage<'a, CombatStats>,
+                        WriteStorage<'a, SufferDamage>,
+                        ReadStorage<'a, AreaOfEffect>,
+                        WriteStorage<'a, Confusion>,
+                        ReadStorage<'a, Equippable>,
+                        WriteStorage<'a, Equipped>,
+                        WriteStorage<'a, InBackpack>,
+                        WriteExpect<'a, ParticleBuilder>,
+                        ReadStorage<'a, Position>,
+                        ReadStorage<'a, ProvidesFood>,
+                        WriteStorage<'a, HungerClock>,
+                        ReadStorage<'a, MagicMapper>,
+                        WriteExpect<'a, RunState>
+                      );
+
+    #[allow(clippy::cognitive_complexity)]
+    fn run(&mut self, data : Self::SystemData) {
+        let (player_entity, mut gamelog, map, entities, mut wants_use, names, 
+            consumables, healing, inflict_damage, mut combat_stats, mut suffer_damage, 
+            aoe, mut confused, equippable, mut equipped, mut backpack, mut particle_builder, positions,
+            provides_food, mut hunger_clocks, magic_mapper, mut runstate) = data;
+
+        for (entity, useitem) in (&entities, &wants_use).join() {
+            let mut used_item = true;
+
+            // Targeting
+            let mut targets : Vec<Entity> = Vec::new();
+            match useitem.target {
+                None => { targets.push( *player_entity ); }
+                Some(target) => {
+                    let area_effect = aoe.get(useitem.item);
+                    match area_effect {
+                        None => {
+                            // Single target in tile
+                            let idx = map.xy_idx(target.x, target.y);
+                            for mob in map.tile_content[idx].iter() {
+                                targets.push(*mob);
+                            }
+                        }
+                        Some(area_effect) => {
+                            // AoE
+                            let blast_tiles = rltk::field_of_view(target, area_effect.radius, &*map);
+                            for tile_idx in blast_tiles.iter() {
+                                let idx = map.xy_idx(tile_idx.x, tile_idx.y);
+                                for mob in map.tile_content[idx].iter() {
+                                    targets.push(*mob);
+                                }
+                                particle_builder.request(tile_idx.x, tile_idx.y, rltk::RGB::named(rltk::ORANGE), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('░'), 200.0);
+                            }
+                        }
+                    }
+                }
+            }
+
+            // If it is equippable, then we want to equip it - and unequip whatever else was in that slot
+            let item_equippable = equippable.get(useitem.item);
+            match item_equippable {
+                None => {}
+                Some(can_equip) => {
+                    let target_slot = can_equip.slot;
+                    let target = targets[0];
+
+                    // Remove any items the target has in the item's slot
+                    let mut to_unequip : Vec<Entity> = Vec::new();
+                    for (item_entity, already_equipped, name) in (&entities, &equipped, &names).join() {
+                        if already_equipped.owner == target && already_equipped.slot == target_slot {
+                            to_unequip.push(item_entity);
+                            if target == *player_entity {
+                                gamelog.entries.insert(0, format!("You unequip {}.", name.name));
+                            }
+                        }
+                    }
+                    for item in to_unequip.iter() {
+                        equipped.remove(*item);
+                        backpack.insert(*item, InBackpack{ owner: target }).expect("Unable to insert backpack entry");
+                    }
+
+                    // Wield the item
+                    equipped.insert(useitem.item, Equipped{ owner: target, slot: target_slot }).expect("Unable to insert equipped component");
+                    backpack.remove(useitem.item);
+                    if target == *player_entity {
+                        gamelog.entries.insert(0, format!("You equip {}.", names.get(useitem.item).unwrap().name));
+                    }
+                }
+            }
+
+            // It it is edible, eat it!
+            let item_edible = provides_food.get(useitem.item);
+            match item_edible {
+                None => {}
+                Some(_) => {
+                    used_item = true;
+                    let target = targets[0];
+                    let hc = hunger_clocks.get_mut(target);
+                    if let Some(hc) = hc {
+                        hc.state = HungerState::WellFed;
+                        hc.duration = 20;
+                        gamelog.entries.insert(0, format!("You eat the {}.", names.get(useitem.item).unwrap().name));
+                    }
+                }
+            }
+
+            // If its a magic mapper...
+            let is_mapper = magic_mapper.get(useitem.item);
+            match is_mapper {
+                None => {}
+                Some(_) => {
+                    used_item = true;
+                    gamelog.entries.insert(0, "The map is revealed to you!".to_string());
+                    *runstate = RunState::MagicMapReveal{ row : 0};
+                }
+            }
+
+            // If it heals, apply the healing
+            let item_heals = healing.get(useitem.item);
+            match item_heals {
+                None => {}
+                Some(healer) => {
+                    used_item = false;
+                    for target in targets.iter() {
+                        let stats = combat_stats.get_mut(*target);
+                        if let Some(stats) = stats {
+                            stats.hp = i32::min(stats.max_hp, stats.hp + healer.heal_amount);
+                            if entity == *player_entity {
+                                gamelog.entries.insert(0, format!("You use the {}, healing {} hp.", names.get(useitem.item).unwrap().name, healer.heal_amount));
+                            }
+                            used_item = true;
+
+                            let pos = positions.get(*target);
+                            if let Some(pos) = pos {
+                                particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::GREEN), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('♥'), 200.0);
+                            }   
+                        }            
+                    }
+                }
+            }
+
+            // If it inflicts damage, apply it to the target cell
+            let item_damages = inflict_damage.get(useitem.item);
+            match item_damages {
+                None => {}
+                Some(damage) => {
+                    used_item = false;
+                    for mob in targets.iter() {
+                        suffer_damage.insert(*mob, SufferDamage{ amount : damage.damage }).expect("Unable to insert");
+                        if entity == *player_entity {
+                            let mob_name = names.get(*mob).unwrap();
+                            let item_name = names.get(useitem.item).unwrap();
+                            gamelog.entries.insert(0, format!("You use {} on {}, inflicting {} hp.", item_name.name, mob_name.name, damage.damage));
+
+                            let pos = positions.get(*mob);
+                            if let Some(pos) = pos {
+                                particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::RED), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('‼'), 200.0);
+                            }
+                        }
+
+                        used_item = true;
+                    }
+                }
+            }
+
+            // Can it pass along confusion? Note the use of scopes to escape from the borrow checker!
+            let mut add_confusion = Vec::new();
+            {
+                let causes_confusion = confused.get(useitem.item);
+                match causes_confusion {
+                    None => {}
+                    Some(confusion) => {
+                        used_item = false;
+                        for mob in targets.iter() {
+                            add_confusion.push((*mob, confusion.turns ));
+                            if entity == *player_entity {
+                                let mob_name = names.get(*mob).unwrap();
+                                let item_name = names.get(useitem.item).unwrap();
+                                gamelog.entries.insert(0, format!("You use {} on {}, confusing them.", item_name.name, mob_name.name));
+
+                                let pos = positions.get(*mob);
+                                if let Some(pos) = pos {
+                                    particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::MAGENTA), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('?'), 200.0);
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+            for mob in add_confusion.iter() {
+                confused.insert(mob.0, Confusion{ turns: mob.1 }).expect("Unable to insert status");
+            }
+
+            // If its a consumable, we delete it on use
+            if used_item {
+                let consumable = consumables.get(useitem.item);
+                match consumable {
+                    None => {}
+                    Some(_) => {
+                        entities.delete(useitem.item).expect("Delete failed");
+                    }
+                }
+            }
+        }
+
+        wants_use.clear();
+    }
+}
+
+pub struct ItemDropSystem {}
+
+impl<'a> System<'a> for ItemDropSystem {
+    #[allow(clippy::type_complexity)]
+    type SystemData = ( ReadExpect<'a, Entity>,
+                        WriteExpect<'a, GameLog>,
+                        Entities<'a>,
+                        WriteStorage<'a, WantsToDropItem>,
+                        ReadStorage<'a, Name>,
+                        WriteStorage<'a, Position>,
+                        WriteStorage<'a, InBackpack>
+                      );
+
+    fn run(&mut self, data : Self::SystemData) {
+        let (player_entity, mut gamelog, entities, mut wants_drop, names, mut positions, mut backpack) = data;
+
+        for (entity, to_drop) in (&entities, &wants_drop).join() {
+            let mut dropper_pos : Position = Position{x:0, y:0};
+            {
+                let dropped_pos = positions.get(entity).unwrap();
+                dropper_pos.x = dropped_pos.x;
+                dropper_pos.y = dropped_pos.y;
+            }
+            positions.insert(to_drop.item, Position{ x : dropper_pos.x, y : dropper_pos.y }).expect("Unable to insert position");
+            backpack.remove(to_drop.item);
+
+            if entity == *player_entity {
+                gamelog.entries.insert(0, format!("You drop up the {}.", names.get(to_drop.item).unwrap().name));
+            }
+        }
+
+        wants_drop.clear();
+    }
+}
+
+pub struct ItemRemoveSystem {}
+
+impl<'a> System<'a> for ItemRemoveSystem {
+    #[allow(clippy::type_complexity)]
+    type SystemData = ( 
+                        Entities<'a>,
+                        WriteStorage<'a, WantsToRemoveItem>,
+                        WriteStorage<'a, Equipped>,
+                        WriteStorage<'a, InBackpack>
+                      );
+
+    fn run(&mut self, data : Self::SystemData) {
+        let (entities, mut wants_remove, mut equipped, mut backpack) = data;
+
+        for (entity, to_remove) in (&entities, &wants_remove).join() {
+            equipped.remove(to_remove.item);
+            backpack.insert(to_remove.item, InBackpack{ owner: entity }).expect("Unable to insert backpack");
+        }
+
+        wants_remove.clear();
+    }
+}

+ 436 - 0
chapter-21-rexmenu/src/main.rs

@@ -0,0 +1,436 @@
+extern crate serde;
+extern crate rltk;
+use rltk::{Console, GameState, Rltk, Point};
+extern crate specs;
+use specs::prelude::*;
+use specs::saveload::{SimpleMarker, SimpleMarkerAllocator};
+#[macro_use]
+extern crate specs_derive;
+mod components;
+pub use components::*;
+mod map;
+pub use map::*;
+mod player;
+use player::*;
+mod rect;
+pub use rect::Rect;
+mod visibility_system;
+use visibility_system::VisibilitySystem;
+mod monster_ai_system;
+use monster_ai_system::MonsterAI;
+mod map_indexing_system;
+use map_indexing_system::MapIndexingSystem;
+mod melee_combat_system;
+use melee_combat_system::MeleeCombatSystem;
+mod damage_system;
+use damage_system::DamageSystem;
+mod gui;
+mod gamelog;
+mod spawner;
+mod inventory_system;
+use inventory_system::{ ItemCollectionSystem, ItemUseSystem, ItemDropSystem, ItemRemoveSystem };
+pub mod saveload_system;
+pub mod random_table;
+pub mod particle_system;
+pub mod hunger_system;
+pub mod rex_assets;
+
+#[derive(PartialEq, Copy, Clone)]
+pub enum RunState { AwaitingInput, 
+    PreRun, 
+    PlayerTurn, 
+    MonsterTurn, 
+    ShowInventory, 
+    ShowDropItem, 
+    ShowTargeting { range : i32, item : Entity},
+    MainMenu { menu_selection : gui::MainMenuSelection },
+    SaveGame,
+    NextLevel,
+    ShowRemoveItem,
+    GameOver,
+    MagicMapReveal { row : i32 }
+}
+
+pub struct State {
+    pub ecs: World,
+    pub systems: Dispatcher<'static, 'static>
+}
+
+impl GameState for State {
+    fn tick(&mut self, ctx : &mut Rltk) {
+        let mut newrunstate;
+        {
+            let runstate = self.ecs.fetch::<RunState>();
+            newrunstate = *runstate;
+        }
+
+        ctx.cls();        
+        particle_system::cull_dead_particles(&mut self.ecs, ctx);
+
+        match newrunstate {
+            RunState::MainMenu{..} => {}
+            RunState::GameOver{..} => {}
+            _ => {
+                draw_map(&self.ecs, ctx);
+                let positions = self.ecs.read_storage::<Position>();
+                let renderables = self.ecs.read_storage::<Renderable>();
+                let map = self.ecs.fetch::<Map>();
+
+                let mut data = (&positions, &renderables).join().collect::<Vec<_>>();
+                data.sort_by(|&a, &b| b.1.render_order.cmp(&a.1.render_order) );
+                for (pos, render) in data.iter() {
+                    let idx = map.xy_idx(pos.x, pos.y);
+                    if map.visible_tiles[idx] { ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph) }
+                }
+                gui::draw_ui(&self.ecs, ctx);                
+            }
+        }
+        
+        match newrunstate {
+            RunState::PreRun => {
+                self.systems.dispatch(&self.ecs);
+                self.ecs.maintain();
+                newrunstate = RunState::AwaitingInput;
+            }
+            RunState::AwaitingInput => {
+                newrunstate = player_input(self, ctx);
+            }
+            RunState::PlayerTurn => {
+                self.systems.dispatch(&self.ecs);
+                self.ecs.maintain();
+                match *self.ecs.fetch::<RunState>() {
+                    RunState::MagicMapReveal{ .. } => newrunstate = RunState::MagicMapReveal{ row: 0 },
+                    _ => newrunstate = RunState::MonsterTurn
+                }                
+            }
+            RunState::MonsterTurn => {
+                self.systems.dispatch(&self.ecs);
+                self.ecs.maintain();
+                newrunstate = RunState::AwaitingInput;
+            }
+            RunState::ShowInventory => {
+                let result = gui::show_inventory(self, ctx);
+                match result.0 {
+                    gui::ItemMenuResult::Cancel => newrunstate = RunState::AwaitingInput,
+                    gui::ItemMenuResult::NoResponse => {}
+                    gui::ItemMenuResult::Selected => {
+                        let item_entity = result.1.unwrap();
+                        let is_ranged = self.ecs.read_storage::<Ranged>();
+                        let is_item_ranged = is_ranged.get(item_entity);
+                        if let Some(is_item_ranged) = is_item_ranged {
+                            newrunstate = RunState::ShowTargeting{ range: is_item_ranged.range, item: item_entity };
+                        } else {
+                            let mut intent = self.ecs.write_storage::<WantsToUseItem>();
+                            intent.insert(*self.ecs.fetch::<Entity>(), WantsToUseItem{ item: item_entity, target: None }).expect("Unable to insert intent");
+                            newrunstate = RunState::PlayerTurn;
+                        }
+                    }
+                }
+            }
+            RunState::ShowDropItem => {
+                let result = gui::drop_item_menu(self, ctx);
+                match result.0 {
+                    gui::ItemMenuResult::Cancel => newrunstate = RunState::AwaitingInput,
+                    gui::ItemMenuResult::NoResponse => {}
+                    gui::ItemMenuResult::Selected => {
+                        let item_entity = result.1.unwrap();
+                        let mut intent = self.ecs.write_storage::<WantsToDropItem>();
+                        intent.insert(*self.ecs.fetch::<Entity>(), WantsToDropItem{ item: item_entity }).expect("Unable to insert intent");
+                        newrunstate = RunState::PlayerTurn;
+                    }
+                }
+            }
+            RunState::ShowRemoveItem => {
+                let result = gui::remove_item_menu(self, ctx);
+                match result.0 {
+                    gui::ItemMenuResult::Cancel => newrunstate = RunState::AwaitingInput,
+                    gui::ItemMenuResult::NoResponse => {}
+                    gui::ItemMenuResult::Selected => {
+                        let item_entity = result.1.unwrap();
+                        let mut intent = self.ecs.write_storage::<WantsToRemoveItem>();
+                        intent.insert(*self.ecs.fetch::<Entity>(), WantsToRemoveItem{ item: item_entity }).expect("Unable to insert intent");
+                        newrunstate = RunState::PlayerTurn;
+                    }
+                }
+            }
+            RunState::ShowTargeting{range, item} => {
+                let result = gui::ranged_target(self, ctx, range);
+                match result.0 {
+                    gui::ItemMenuResult::Cancel => newrunstate = RunState::AwaitingInput,
+                    gui::ItemMenuResult::NoResponse => {}
+                    gui::ItemMenuResult::Selected => {
+                        let mut intent = self.ecs.write_storage::<WantsToUseItem>();
+                        intent.insert(*self.ecs.fetch::<Entity>(), WantsToUseItem{ item, target: result.1 }).expect("Unable to insert intent");
+                        newrunstate = RunState::PlayerTurn;
+                    }
+                }
+            }
+            RunState::MainMenu{ .. } => {
+                let result = gui::main_menu(self, ctx);
+                match result {
+                    gui::MainMenuResult::NoSelection{ selected } => newrunstate = RunState::MainMenu{ menu_selection: selected },
+                    gui::MainMenuResult::Selected{ selected } => {
+                        match selected {
+                            gui::MainMenuSelection::NewGame => newrunstate = RunState::PreRun,
+                            gui::MainMenuSelection::LoadGame => {
+                                saveload_system::load_game(&mut self.ecs);
+                                newrunstate = RunState::AwaitingInput;
+                                saveload_system::delete_save();
+                            }
+                            gui::MainMenuSelection::Quit => { ::std::process::exit(0); }
+                        }
+                    }
+                }
+            }
+            RunState::GameOver => {
+                let result = gui::game_over(ctx);
+                match result {
+                    gui::GameOverResult::NoSelection => {}
+                    gui::GameOverResult::QuitToMenu => {
+                        self.game_over_cleanup();
+                        newrunstate = RunState::MainMenu{ menu_selection: gui::MainMenuSelection::NewGame };
+                    }
+                }
+            }
+            RunState::SaveGame => {
+                saveload_system::save_game(&mut self.ecs);
+                newrunstate = RunState::MainMenu{ menu_selection : gui::MainMenuSelection::LoadGame };
+            }
+            RunState::NextLevel => {
+                self.goto_next_level();                
+                newrunstate = RunState::PreRun;
+            }
+            RunState::MagicMapReveal{row} => {
+                let mut map = self.ecs.fetch_mut::<Map>();
+                for x in 0..MAPWIDTH {
+                    let idx = map.xy_idx(x as i32,row);
+                    map.revealed_tiles[idx] = true;
+                }
+                if row as usize == MAPHEIGHT-1 {
+                    newrunstate = RunState::MonsterTurn;
+                } else {
+                    newrunstate = RunState::MagicMapReveal{ row: row+1 };
+                }
+            }
+        }
+
+        {
+            let mut runwriter = self.ecs.write_resource::<RunState>();
+            *runwriter = newrunstate;
+        }
+        damage_system::delete_the_dead(&mut self.ecs);
+    }
+}
+
+impl State {
+    fn entities_to_remove_on_level_change(&mut self) -> Vec<Entity> {
+        let entities = self.ecs.entities();
+        let player = self.ecs.read_storage::<Player>();
+        let backpack = self.ecs.read_storage::<InBackpack>();
+        let player_entity = self.ecs.fetch::<Entity>();
+        let equipped = self.ecs.read_storage::<Equipped>();
+
+        let mut to_delete : Vec<Entity> = Vec::new();
+        for entity in entities.join() {
+            let mut should_delete = true;
+
+            // Don't delete the player
+            let p = player.get(entity);
+            if let Some(_p) = p {
+                should_delete = false;
+            }
+
+            // Don't delete the player's equipment
+            let bp = backpack.get(entity);
+            if let Some(bp) = bp {
+                if bp.owner == *player_entity {
+                    should_delete = false;
+                }
+            }
+
+            let eq = equipped.get(entity);
+            if let Some(eq) = eq {
+                if eq.owner == *player_entity {
+                    should_delete = false;
+                }
+            }
+
+            if should_delete { 
+                to_delete.push(entity);
+            }
+        }
+
+        to_delete
+    }
+
+    fn goto_next_level(&mut self) {
+        // Delete entities that aren't the player or his/her equipment
+        let to_delete = self.entities_to_remove_on_level_change();
+        for target in to_delete {
+            self.ecs.delete_entity(target).expect("Unable to delete entity");
+        }
+
+        // Build a new map and place the player
+        let worldmap;
+        let current_depth;
+        {
+            let mut worldmap_resource = self.ecs.write_resource::<Map>();
+            current_depth = worldmap_resource.depth;
+            *worldmap_resource = Map::new_map_rooms_and_corridors(current_depth + 1);
+            worldmap = worldmap_resource.clone();
+        }
+
+        // Spawn bad guys
+        for room in worldmap.rooms.iter().skip(1) {
+            spawner::spawn_room(&mut self.ecs, room, current_depth+1);
+        }
+
+        // Place the player and update resources
+        let (player_x, player_y) = worldmap.rooms[0].center();
+        let mut player_position = self.ecs.write_resource::<Point>();
+        *player_position = Point::new(player_x, player_y);
+        let mut position_components = self.ecs.write_storage::<Position>();
+        let player_entity = self.ecs.fetch::<Entity>();
+        let player_pos_comp = position_components.get_mut(*player_entity);
+        if let Some(player_pos_comp) = player_pos_comp {
+            player_pos_comp.x = player_x;
+            player_pos_comp.y = player_y;
+        }
+
+        // Mark the player's visibility as dirty
+        let mut viewshed_components = self.ecs.write_storage::<Viewshed>();
+        let vs = viewshed_components.get_mut(*player_entity);
+        if let Some(vs) = vs {
+            vs.dirty = true;
+        }        
+
+        // Notify the player and give them some health
+        let mut gamelog = self.ecs.fetch_mut::<gamelog::GameLog>();
+        gamelog.entries.insert(0, "You descend to the next level, and take a moment to heal.".to_string());
+        let mut player_health_store = self.ecs.write_storage::<CombatStats>();
+        let player_health = player_health_store.get_mut(*player_entity);
+        if let Some(player_health) = player_health {
+            player_health.hp = i32::max(player_health.hp, player_health.max_hp / 2);
+        }
+    }
+
+    fn game_over_cleanup(&mut self) {
+        // Delete everything
+        let mut to_delete = Vec::new();
+        for e in self.ecs.entities().join() {
+            to_delete.push(e);
+        }
+        for del in to_delete.iter() {
+            self.ecs.delete_entity(*del).expect("Deletion failed");
+        }
+
+        // Build a new map and place the player
+        let worldmap;
+        {
+            let mut worldmap_resource = self.ecs.write_resource::<Map>();
+            *worldmap_resource = Map::new_map_rooms_and_corridors(1);
+            worldmap = worldmap_resource.clone();
+        }
+
+        // Spawn bad guys
+        for room in worldmap.rooms.iter().skip(1) {
+            spawner::spawn_room(&mut self.ecs, room, 1);
+        }
+
+        // Place the player and update resources
+        let (player_x, player_y) = worldmap.rooms[0].center();
+        let player_entity = spawner::player(&mut self.ecs, player_x, player_y);
+        let mut player_position = self.ecs.write_resource::<Point>();
+        *player_position = Point::new(player_x, player_y);
+        let mut position_components = self.ecs.write_storage::<Position>();
+        let mut player_entity_writer = self.ecs.write_resource::<Entity>();
+        *player_entity_writer = player_entity;
+        let player_pos_comp = position_components.get_mut(player_entity);
+        if let Some(player_pos_comp) = player_pos_comp {
+            player_pos_comp.x = player_x;
+            player_pos_comp.y = player_y;
+        }
+
+        // Mark the player's visibility as dirty
+        let mut viewshed_components = self.ecs.write_storage::<Viewshed>();
+        let vs = viewshed_components.get_mut(player_entity);
+        if let Some(vs) = vs {
+            vs.dirty = true;
+        }                                               
+    }
+}
+
+fn main() {
+    let mut context = Rltk::init_simple8x8(80, 50, "Hello Rust World", "../resources");
+    context.with_post_scanlines(true);
+    let mut gs = State {
+        ecs: World::new(),
+        systems : DispatcherBuilder::new()
+            .with(MapIndexingSystem{}, "map_indexing_system", &[])
+            .with(VisibilitySystem{}, "visibility_system", &[])
+            .with(MonsterAI{}, "monster_ai", &["visibility_system", "map_indexing_system"])
+            .with(MeleeCombatSystem{}, "melee_combat", &["monster_ai"])
+            .with(DamageSystem{}, "damage", &["melee_combat"])
+            .with(ItemCollectionSystem{}, "pickup", &["melee_combat"])
+            .with(ItemUseSystem{}, "potions", &["melee_combat"])
+            .with(ItemDropSystem{}, "drop_items", &["melee_combat"])
+            .with(ItemRemoveSystem{}, "remove_items", &["melee_combat"])
+            .with(hunger_system::HungerSystem{}, "hunger", &["melee_combat", "potions"])
+            .with(particle_system::ParticleSpawnSystem{}, "spawn_particles", &["potions", "melee_combat"])
+            .build(),
+    };
+    gs.ecs.register::<Position>();
+    gs.ecs.register::<Renderable>();
+    gs.ecs.register::<Player>();
+    gs.ecs.register::<Viewshed>();
+    gs.ecs.register::<Monster>();
+    gs.ecs.register::<Name>();
+    gs.ecs.register::<BlocksTile>();
+    gs.ecs.register::<CombatStats>();
+    gs.ecs.register::<WantsToMelee>();
+    gs.ecs.register::<SufferDamage>();
+    gs.ecs.register::<Item>();
+    gs.ecs.register::<ProvidesHealing>();
+    gs.ecs.register::<InflictsDamage>();
+    gs.ecs.register::<AreaOfEffect>();
+    gs.ecs.register::<Consumable>();
+    gs.ecs.register::<Ranged>();
+    gs.ecs.register::<InBackpack>();
+    gs.ecs.register::<WantsToPickupItem>();
+    gs.ecs.register::<WantsToUseItem>();
+    gs.ecs.register::<WantsToDropItem>();
+    gs.ecs.register::<Confusion>();
+    gs.ecs.register::<SimpleMarker<SerializeMe>>();
+    gs.ecs.register::<SerializationHelper>();
+    gs.ecs.register::<Equippable>();
+    gs.ecs.register::<Equipped>();
+    gs.ecs.register::<MeleePowerBonus>();
+    gs.ecs.register::<DefenseBonus>();
+    gs.ecs.register::<WantsToRemoveItem>();
+    gs.ecs.register::<ParticleLifetime>();
+    gs.ecs.register::<HungerClock>();
+    gs.ecs.register::<ProvidesFood>();
+    gs.ecs.register::<MagicMapper>();
+
+    gs.ecs.insert(SimpleMarkerAllocator::<SerializeMe>::new());
+
+    let map : Map = Map::new_map_rooms_and_corridors(1);
+    let (player_x, player_y) = map.rooms[0].center();
+
+    let player_entity = spawner::player(&mut gs.ecs, player_x, player_y);
+
+    gs.ecs.insert(rltk::RandomNumberGenerator::new());
+    for room in map.rooms.iter().skip(1) {
+        spawner::spawn_room(&mut gs.ecs, room, 1);
+    }
+
+    gs.ecs.insert(map);
+    gs.ecs.insert(Point::new(player_x, player_y));
+    gs.ecs.insert(player_entity);
+    gs.ecs.insert(RunState::MainMenu{ menu_selection: gui::MainMenuSelection::NewGame });
+    gs.ecs.insert(gamelog::GameLog{ entries : vec!["Welcome to Rusty Roguelike".to_string()] });
+    gs.ecs.insert(particle_system::ParticleBuilder::new());
+    gs.ecs.insert(rex_assets::RexAssets::new());
+
+    rltk::main_loop(context, gs);
+}

+ 262 - 0
chapter-21-rexmenu/src/map.rs

@@ -0,0 +1,262 @@
+extern crate rltk;
+use rltk::{ RGB, Rltk, Console, RandomNumberGenerator, BaseMap, Algorithm2D, Point };
+use super::{Rect};
+use std::cmp::{max, min};
+extern crate specs;
+use specs::prelude::*;
+use serde::{Serialize, Deserialize};
+use std::collections::HashSet;
+
+pub const MAPWIDTH : usize = 80;
+pub const MAPHEIGHT : usize = 43;
+pub const MAPCOUNT : usize = MAPHEIGHT * MAPWIDTH;
+
+#[derive(PartialEq, Copy, Clone, Serialize, Deserialize)]
+pub enum TileType {
+    Wall, Floor, DownStairs
+}
+
+#[derive(Default, Serialize, Deserialize, Clone)]
+pub struct Map {
+    pub tiles : Vec<TileType>,
+    pub rooms : Vec<Rect>,
+    pub width : i32,
+    pub height : i32,
+    pub revealed_tiles : Vec<bool>,
+    pub visible_tiles : Vec<bool>,
+    pub blocked : Vec<bool>,
+    pub depth : i32,
+    pub bloodstains : HashSet<usize>,
+
+    #[serde(skip_serializing)]
+    #[serde(skip_deserializing)]
+    pub tile_content : Vec<Vec<Entity>>
+}
+
+impl Map {
+    pub fn xy_idx(&self, x: i32, y: i32) -> usize {
+        (y as usize * self.width as usize) + x as usize
+    }
+
+    fn apply_room_to_map(&mut self, room : &Rect) {
+        for y in room.y1 +1 ..= room.y2 {
+            for x in room.x1 + 1 ..= room.x2 {
+                let idx = self.xy_idx(x, y);
+                self.tiles[idx] = TileType::Floor;
+            }
+        }
+    }
+
+    fn apply_horizontal_tunnel(&mut self, x1:i32, x2:i32, y:i32) {
+        for x in min(x1,x2) ..= max(x1,x2) {
+            let idx = self.xy_idx(x, y);
+            if idx > 0 && idx < self.width as usize * self.height as usize {
+                self.tiles[idx as usize] = TileType::Floor;
+            }
+        }
+    }
+
+    fn apply_vertical_tunnel(&mut self, y1:i32, y2:i32, x:i32) {
+        for y in min(y1,y2) ..= max(y1,y2) {
+            let idx = self.xy_idx(x, y);
+            if idx > 0 && idx < self.width as usize * self.height as usize {
+                self.tiles[idx as usize] = TileType::Floor;
+            }
+        }
+    }
+
+    fn is_exit_valid(&self, x:i32, y:i32) -> bool {
+        if x < 1 || x > self.width-1 || y < 1 || y > self.height-1 { return false; }
+        let idx = (y * self.width) + x;
+        !self.blocked[idx as usize]
+    }
+
+    pub fn populate_blocked(&mut self) {        
+        for (i,tile) in self.tiles.iter_mut().enumerate() {
+            self.blocked[i] = *tile == TileType::Wall;
+        }
+    }
+
+    pub fn clear_content_index(&mut self) {
+        for content in self.tile_content.iter_mut() {
+            content.clear();
+        }
+    }
+
+    /// Makes a new map using the algorithm from http://rogueliketutorials.com/tutorials/tcod/part-3/
+    /// This gives a handful of random rooms and corridors joining them together.
+    pub fn new_map_rooms_and_corridors(new_depth : i32) -> Map {
+        let mut map = Map{
+            tiles : vec![TileType::Wall; MAPCOUNT],
+            rooms : Vec::new(),
+            width : MAPWIDTH as i32,
+            height: MAPHEIGHT as i32,
+            revealed_tiles : vec![false; MAPCOUNT],
+            visible_tiles : vec![false; MAPCOUNT],
+            blocked : vec![false; MAPCOUNT],
+            tile_content : vec![Vec::new(); MAPCOUNT],
+            depth: new_depth,
+            bloodstains: HashSet::new()
+        };
+
+        const MAX_ROOMS : i32 = 30;
+        const MIN_SIZE : i32 = 6;
+        const MAX_SIZE : i32 = 10;
+
+        let mut rng = RandomNumberGenerator::new();
+
+        for _i in 0..MAX_ROOMS {
+            let w = rng.range(MIN_SIZE, MAX_SIZE);
+            let h = rng.range(MIN_SIZE, MAX_SIZE);
+            let x = rng.roll_dice(1, map.width - w - 1) - 1;
+            let y = rng.roll_dice(1, map.height - h - 1) - 1;
+            let new_room = Rect::new(x, y, w, h);
+            let mut ok = true;
+            for other_room in map.rooms.iter() {
+                if new_room.intersect(other_room) { ok = false }
+            }
+            if ok {
+                map.apply_room_to_map(&new_room);
+
+                if !map.rooms.is_empty() {
+                    let (new_x, new_y) = new_room.center();
+                    let (prev_x, prev_y) = map.rooms[map.rooms.len()-1].center();
+                    if rng.range(0,1) == 1 {
+                        map.apply_horizontal_tunnel(prev_x, new_x, prev_y);
+                        map.apply_vertical_tunnel(prev_y, new_y, new_x);
+                    } else {
+                        map.apply_vertical_tunnel(prev_y, new_y, prev_x);
+                        map.apply_horizontal_tunnel(prev_x, new_x, new_y);
+                    }
+                }
+
+                map.rooms.push(new_room);
+            }
+        }
+
+        let stairs_position = map.rooms[map.rooms.len()-1].center();
+        let stairs_idx = map.xy_idx(stairs_position.0, stairs_position.1);
+        map.tiles[stairs_idx] = TileType::DownStairs;
+
+        map
+    }
+}
+
+impl BaseMap for Map {
+    fn is_opaque(&self, idx:i32) -> bool {
+        self.tiles[idx as usize] == TileType::Wall
+    }
+
+    fn get_available_exits(&self, idx:i32) -> Vec<(i32, f32)> {
+        let mut exits : Vec<(i32, f32)> = Vec::new();
+        let x = idx % self.width;
+        let y = idx / self.width;
+
+        // Cardinal directions
+        if self.is_exit_valid(x-1, y) { exits.push((idx-1, 1.0)) };
+        if self.is_exit_valid(x+1, y) { exits.push((idx+1, 1.0)) };
+        if self.is_exit_valid(x, y-1) { exits.push((idx-self.width, 1.0)) };
+        if self.is_exit_valid(x, y+1) { exits.push((idx+self.width, 1.0)) };
+
+        // Diagonals
+        if self.is_exit_valid(x-1, y-1) { exits.push(((idx-self.width)-1, 1.45)); }
+        if self.is_exit_valid(x+1, y-1) { exits.push(((idx-self.width)+1, 1.45)); }
+        if self.is_exit_valid(x-1, y+1) { exits.push(((idx+self.width)-1, 1.45)); }
+        if self.is_exit_valid(x+1, y+1) { exits.push(((idx+self.width)+1, 1.45)); }
+
+        exits
+    }
+
+    fn get_pathing_distance(&self, idx1:i32, idx2:i32) -> f32 {
+        let p1 = Point::new(idx1 % self.width, idx1 / self.width);
+        let p2 = Point::new(idx2 % self.width, idx2 / self.width);
+        rltk::DistanceAlg::Pythagoras.distance2d(p1, p2)
+    }
+}
+
+impl Algorithm2D for Map {
+    fn point2d_to_index(&self, pt: Point) -> i32 {
+        (pt.y * self.width) + pt.x
+    }
+
+    fn index_to_point2d(&self, idx:i32) -> Point {
+        Point{ x: idx % self.width, y: idx / self.width }
+    }
+}
+
+fn is_revealed_and_wall(map: &Map, x: i32, y: i32) -> bool {
+    let idx = map.xy_idx(x, y);
+    map.tiles[idx] == TileType::Wall && map.revealed_tiles[idx]
+}
+
+fn wall_glyph(map : &Map, x: i32, y:i32) -> u8 {
+    if x < 1 || x > map.width-2 || y < 1 || y > map.height-2 as i32 { return 35; }
+    let mut mask : u8 = 0;
+
+    if is_revealed_and_wall(map, x, y - 1) { mask +=1; }
+    if is_revealed_and_wall(map, x, y + 1) { mask +=2; }
+    if is_revealed_and_wall(map, x - 1, y) { mask +=4; }
+    if is_revealed_and_wall(map, x + 1, y) { mask +=8; }
+
+    match mask {
+        0 => { 9 } // Pillar because we can't see neighbors
+        1 => { 186 } // Wall only to the north
+        2 => { 186 } // Wall only to the south
+        3 => { 186 } // Wall to the north and south
+        4 => { 205 } // Wall only to the west
+        5 => { 188 } // Wall to the north and west
+        6 => { 187 } // Wall to the south and west
+        7 => { 185 } // Wall to the north, south and west
+        8 => { 205 } // Wall only to the east
+        9 => { 200 } // Wall to the north and east
+        10 => { 201 } // Wall to the south and east
+        11 => { 204 } // Wall to the north, south and east
+        12 => { 205 } // Wall to the east and west
+        13 => { 202 } // Wall to the east, west, and south
+        14 => { 203 } // Wall to the east, west, and north
+        _ => { 35 } // We missed one?
+    }
+}
+
+pub fn draw_map(ecs: &World, ctx : &mut Rltk) {
+    let map = ecs.fetch::<Map>();
+
+    let mut y = 0;
+    let mut x = 0;
+    for (idx,tile) in map.tiles.iter().enumerate() {
+        // Render a tile depending upon the tile type
+
+        if map.revealed_tiles[idx] {
+            let glyph;
+            let mut fg;
+            let mut bg = RGB::from_f32(0., 0., 0.);
+            match tile {
+                TileType::Floor => {
+                    glyph = rltk::to_cp437('.');
+                    fg = RGB::from_f32(0.0, 0.5, 0.5);
+                }
+                TileType::Wall => {
+                    glyph = wall_glyph(&*map, x, y);
+                    fg = RGB::from_f32(0., 1.0, 0.);
+                }
+                TileType::DownStairs => {
+                    glyph = rltk::to_cp437('>');
+                    fg = RGB::from_f32(0., 1.0, 1.0);
+                }
+            }
+            if map.bloodstains.contains(&idx) { bg = RGB::from_f32(0.75, 0., 0.); }
+            if !map.visible_tiles[idx] { 
+                fg = fg.to_greyscale();
+                bg = RGB::from_f32(0., 0., 0.); // Don't show stains out of visual range
+            }
+            ctx.set(x, y, fg, bg, glyph);
+        }
+
+        // Move the coordinates
+        x += 1;
+        if x > MAPWIDTH as i32-1 {
+            x = 0;
+            y += 1;
+        }
+    }
+}

+ 32 - 0
chapter-21-rexmenu/src/map_indexing_system.rs

@@ -0,0 +1,32 @@
+extern crate specs;
+use specs::prelude::*;
+use super::{Map, Position, BlocksTile};
+
+pub struct MapIndexingSystem {}
+
+impl<'a> System<'a> for MapIndexingSystem {
+    type SystemData = ( WriteExpect<'a, Map>,
+                        ReadStorage<'a, Position>,
+                        ReadStorage<'a, BlocksTile>,
+                        Entities<'a>,);
+
+    fn run(&mut self, data : Self::SystemData) {
+        let (mut map, position, blockers, entities) = data;
+
+        map.populate_blocked();
+        map.clear_content_index();
+        for (entity, position) in (&entities, &position).join() {
+            let idx = map.xy_idx(position.x, position.y);
+
+            // If they block, update the blocking list
+            let _p : Option<&BlocksTile> = blockers.get(entity);
+            if let Some(_p) = _p {
+                map.blocked[idx] = true;
+            }
+            
+            // Push the entity to the appropriate index slot. It's a Copy
+            // type, so we don't need to clone it (we want to avoid moving it out of the ECS!)
+            map.tile_content[idx].push(entity);
+        }
+    }
+}

+ 74 - 0
chapter-21-rexmenu/src/melee_combat_system.rs

@@ -0,0 +1,74 @@
+extern crate specs;
+use specs::prelude::*;
+use super::{CombatStats, WantsToMelee, Name, SufferDamage, gamelog::GameLog, MeleePowerBonus, DefenseBonus, Equipped,
+    particle_system::ParticleBuilder, Position, HungerClock, HungerState};
+
+pub struct MeleeCombatSystem {}
+
+impl<'a> System<'a> for MeleeCombatSystem {
+    #[allow(clippy::type_complexity)]
+    type SystemData = ( Entities<'a>,
+                        WriteExpect<'a, GameLog>,
+                        WriteStorage<'a, WantsToMelee>,
+                        ReadStorage<'a, Name>,
+                        ReadStorage<'a, CombatStats>,
+                        WriteStorage<'a, SufferDamage>,
+                        ReadStorage<'a, MeleePowerBonus>,
+                        ReadStorage<'a, DefenseBonus>,
+                        ReadStorage<'a, Equipped>,
+                        WriteExpect<'a, ParticleBuilder>,
+                        ReadStorage<'a, Position>,
+                        ReadStorage<'a, HungerClock>
+                      );
+
+    fn run(&mut self, data : Self::SystemData) {
+        let (entities, mut log, mut wants_melee, names, combat_stats, mut inflict_damage, 
+            melee_power_bonuses, defense_bonuses, equipped, mut particle_builder, positions, hunger_clock) = data;
+
+        for (entity, wants_melee, name, stats) in (&entities, &wants_melee, &names, &combat_stats).join() {
+            if stats.hp > 0 {
+                let mut offensive_bonus = 0;
+                for (_item_entity, power_bonus, equipped_by) in (&entities, &melee_power_bonuses, &equipped).join() {
+                    if equipped_by.owner == entity {
+                        offensive_bonus += power_bonus.power;
+                    }
+                }
+
+                let hc = hunger_clock.get(entity);
+                if let Some(hc) = hc {
+                    if hc.state == HungerState::WellFed {
+                        offensive_bonus += 1;
+                    }
+                }
+
+                let target_stats = combat_stats.get(wants_melee.target).unwrap();
+                if target_stats.hp > 0 {
+                    let target_name = names.get(wants_melee.target).unwrap();
+
+                    let mut defensive_bonus = 0;
+                    for (_item_entity, defense_bonus, equipped_by) in (&entities, &defense_bonuses, &equipped).join() {
+                        if equipped_by.owner == wants_melee.target {
+                            defensive_bonus += defense_bonus.defense;
+                        }
+                    }
+
+                    let pos = positions.get(wants_melee.target);
+                    if let Some(pos) = pos {
+                        particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::ORANGE), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('‼'), 200.0);
+                    }
+
+                    let damage = i32::max(0, (stats.power + offensive_bonus) - (target_stats.defense + defensive_bonus));
+
+                    if damage == 0 {
+                        log.entries.insert(0, format!("{} is unable to hurt {}", &name.name, &target_name.name));
+                    } else {
+                        log.entries.insert(0, format!("{} hits {}, for {} hp.", &name.name, &target_name.name, damage));
+                        inflict_damage.insert(wants_melee.target, SufferDamage{ amount: damage }).expect("Unable to do damage");                        
+                    }
+                }
+            }
+        }
+
+        wants_melee.clear();
+    }
+}

+ 69 - 0
chapter-21-rexmenu/src/monster_ai_system.rs

@@ -0,0 +1,69 @@
+extern crate specs;
+use specs::prelude::*;
+use super::{Viewshed, Monster, Map, Position, WantsToMelee, RunState, Confusion, particle_system::ParticleBuilder};
+extern crate rltk;
+use rltk::{Point};
+
+pub struct MonsterAI {}
+
+impl<'a> System<'a> for MonsterAI {
+    #[allow(clippy::type_complexity)]
+    type SystemData = ( WriteExpect<'a, Map>,
+                        ReadExpect<'a, Point>,
+                        ReadExpect<'a, Entity>,
+                        ReadExpect<'a, RunState>,
+                        Entities<'a>,
+                        WriteStorage<'a, Viewshed>, 
+                        ReadStorage<'a, Monster>,
+                        WriteStorage<'a, Position>,
+                        WriteStorage<'a, WantsToMelee>,
+                        WriteStorage<'a, Confusion>,
+                        WriteExpect<'a, ParticleBuilder>);
+
+    fn run(&mut self, data : Self::SystemData) {
+        let (mut map, player_pos, player_entity, runstate, entities, mut viewshed, 
+            monster, mut position, mut wants_to_melee, mut confused, mut particle_builder) = data;
+
+        if *runstate != RunState::MonsterTurn { return; }
+
+        for (entity, mut viewshed,_monster,mut pos) in (&entities, &mut viewshed, &monster, &mut position).join() {
+            let mut can_act = true;
+
+            let is_confused = confused.get_mut(entity);
+            if let Some(i_am_confused) = is_confused {
+                i_am_confused.turns -= 1;
+                if i_am_confused.turns < 1 {
+                    confused.remove(entity);
+                }
+                can_act = false;
+
+                particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::MAGENTA), 
+                    rltk::RGB::named(rltk::BLACK), rltk::to_cp437('?'), 200.0);
+            }
+
+            if can_act {
+                let distance = rltk::DistanceAlg::Pythagoras.distance2d(Point::new(pos.x, pos.y), *player_pos);
+                if distance < 1.5 {
+                    wants_to_melee.insert(entity, WantsToMelee{ target: *player_entity }).expect("Unable to insert attack");
+                }
+                else if viewshed.visible_tiles.contains(&*player_pos) {
+                    // Path to the player
+                    let path = rltk::a_star_search(
+                        map.xy_idx(pos.x, pos.y) as i32, 
+                        map.xy_idx(player_pos.x, player_pos.y) as i32, 
+                        &mut *map
+                    );
+                    if path.success && path.steps.len()>1 {
+                        let mut idx = map.xy_idx(pos.x, pos.y);
+                        map.blocked[idx] = false;
+                        pos.x = path.steps[1] % map.width;
+                        pos.y = path.steps[1] / map.width;
+                        idx = map.xy_idx(pos.x, pos.y);
+                        map.blocked[idx] = true;
+                        viewshed.dirty = true;
+                    }
+                }
+            }
+        }
+    }
+}

+ 73 - 0
chapter-21-rexmenu/src/particle_system.rs

@@ -0,0 +1,73 @@
+use specs::prelude::*;
+use super::{ Rltk, ParticleLifetime, Position, Renderable };
+use rltk::RGB;
+
+pub fn cull_dead_particles(ecs : &mut World, ctx : &Rltk) {
+    let mut dead_particles : Vec<Entity> = Vec::new();
+    {
+        // Age out particles
+        let mut particles = ecs.write_storage::<ParticleLifetime>();
+        let entities = ecs.entities();
+        for (entity, mut particle) in (&entities, &mut particles).join() {
+            particle.lifetime_ms -= ctx.frame_time_ms;
+            if particle.lifetime_ms < 0.0 {
+                dead_particles.push(entity);
+            }
+        }                    
+    }
+    for dead in dead_particles.iter() {
+        ecs.delete_entity(*dead).expect("Particle will not die");
+    } 
+}
+
+struct ParticleRequest {
+    x: i32,
+    y: i32,
+    fg: RGB,
+    bg: RGB,
+    glyph: u8,
+    lifetime: f32
+}
+
+pub struct ParticleBuilder {
+    requests : Vec<ParticleRequest>
+}
+
+impl ParticleBuilder {
+    pub fn new() -> ParticleBuilder {
+        ParticleBuilder{ requests : Vec::new() }
+    }
+
+    pub fn request(&mut self, x:i32, y:i32, fg: RGB, bg:RGB, glyph: u8, lifetime: f32) {
+        self.requests.push(
+            ParticleRequest{
+                x, y, fg, bg, glyph, lifetime
+            }
+        );
+    }
+}
+
+pub struct ParticleSpawnSystem {}
+
+impl<'a> System<'a> for ParticleSpawnSystem {
+    #[allow(clippy::type_complexity)]
+    type SystemData = ( 
+                        Entities<'a>,
+                        WriteStorage<'a, Position>,
+                        WriteStorage<'a, Renderable>,
+                        WriteStorage<'a, ParticleLifetime>,
+                        WriteExpect<'a, ParticleBuilder>
+                      );
+
+    fn run(&mut self, data : Self::SystemData) {
+        let (entities, mut positions, mut renderables, mut particles, mut particle_builder) = data;
+        for new_particle in particle_builder.requests.iter() {
+            let p = entities.create();
+            positions.insert(p, Position{ x: new_particle.x, y: new_particle.y }).expect("Unable to inser position");
+            renderables.insert(p, Renderable{ fg: new_particle.fg, bg: new_particle.bg, glyph: new_particle.glyph, render_order: 0 }).expect("Unable to insert renderable");
+            particles.insert(p, ParticleLifetime{ lifetime_ms: new_particle.lifetime }).expect("Unable to insert lifetime");
+        }
+
+        particle_builder.requests.clear();
+    }
+}

+ 179 - 0
chapter-21-rexmenu/src/player.rs

@@ -0,0 +1,179 @@
+extern crate rltk;
+use rltk::{VirtualKeyCode, Rltk, Point};
+extern crate specs;
+use specs::prelude::*;
+use super::{Position, Player, Viewshed, State, Map, RunState, CombatStats, WantsToMelee, Item,
+    gamelog::GameLog, WantsToPickupItem, TileType, Monster, HungerClock, HungerState};
+
+pub fn try_move_player(delta_x: i32, delta_y: i32, ecs: &mut World) {
+    let mut positions = ecs.write_storage::<Position>();
+    let players = ecs.read_storage::<Player>();
+    let mut viewsheds = ecs.write_storage::<Viewshed>();
+    let entities = ecs.entities();
+    let combat_stats = ecs.read_storage::<CombatStats>();
+    let map = ecs.fetch::<Map>();
+    let mut wants_to_melee = ecs.write_storage::<WantsToMelee>();
+
+    for (entity, _player, pos, viewshed) in (&entities, &players, &mut positions, &mut viewsheds).join() {
+        let destination_idx = map.xy_idx(pos.x + delta_x, pos.y + delta_y);
+
+        for potential_target in map.tile_content[destination_idx].iter() {
+            let target = combat_stats.get(*potential_target);
+            if let Some(_target) = target {
+                wants_to_melee.insert(entity, WantsToMelee{ target: *potential_target }).expect("Add target failed");
+                return;
+            }
+        }
+
+        if !map.blocked[destination_idx] {
+            pos.x += delta_x;
+            pos.y += delta_y;
+
+            if pos.x < 0 { pos.x = 0; }
+            if pos.x > 79 { pos.y = 79; }
+            if pos.y < 0 { pos.y = 0; }
+            if pos.y > 49 { pos.y = 49; }
+
+            viewshed.dirty = true;
+            let mut ppos = ecs.write_resource::<Point>();
+            ppos.x = pos.x;
+            ppos.y = pos.y;
+        }
+    }
+}
+
+pub fn try_next_level(ecs: &mut World) -> bool {
+    let player_pos = ecs.fetch::<Point>();
+    let map = ecs.fetch::<Map>();
+    let player_idx = map.xy_idx(player_pos.x, player_pos.y);
+    if map.tiles[player_idx] == TileType::DownStairs {
+        true
+    } else {
+        let mut gamelog = ecs.fetch_mut::<GameLog>();
+        gamelog.entries.insert(0, "There is no way down from here.".to_string());
+        false
+    }
+}
+
+fn get_item(ecs: &mut World) {
+    let player_pos = ecs.fetch::<Point>();
+    let player_entity = ecs.fetch::<Entity>();
+    let entities = ecs.entities();
+    let items = ecs.read_storage::<Item>();
+    let positions = ecs.read_storage::<Position>();
+    let mut gamelog = ecs.fetch_mut::<GameLog>();    
+
+    let mut target_item : Option<Entity> = None;
+    for (item_entity, _item, position) in (&entities, &items, &positions).join() {
+        if position.x == player_pos.x && position.y == player_pos.y {
+            target_item = Some(item_entity);
+        }
+    }
+
+    match target_item {
+        None => gamelog.entries.insert(0, "There is nothing here to pick up.".to_string()),
+        Some(item) => {
+            let mut pickup = ecs.write_storage::<WantsToPickupItem>();
+            pickup.insert(*player_entity, WantsToPickupItem{ collected_by: *player_entity, item }).expect("Unable to insert want to pickup");
+        }
+    }
+}
+
+fn skip_turn(ecs: &mut World) -> RunState {
+    let player_entity = ecs.fetch::<Entity>();
+    let viewshed_components = ecs.read_storage::<Viewshed>();
+    let monsters = ecs.read_storage::<Monster>();
+
+    let worldmap_resource = ecs.fetch::<Map>();
+
+    let mut can_heal = true;
+    let viewshed = viewshed_components.get(*player_entity).unwrap();
+    for tile in viewshed.visible_tiles.iter() {
+        let idx = worldmap_resource.xy_idx(tile.x, tile.y);
+        for entity_id in worldmap_resource.tile_content[idx].iter() {
+            let mob = monsters.get(*entity_id);
+            match mob {
+                None => {}
+                Some(_) => { can_heal = false; }
+            }
+        }
+    }
+
+    let hunger_clocks = ecs.read_storage::<HungerClock>();
+    let hc = hunger_clocks.get(*player_entity);
+    if let Some(hc) = hc {
+        match hc.state {
+            HungerState::Hungry => can_heal = false,
+            HungerState::Starving => can_heal = false,
+            _ => {}
+        }
+    }
+
+    if can_heal {
+        let mut health_components = ecs.write_storage::<CombatStats>();
+        let player_hp = health_components.get_mut(*player_entity).unwrap();
+        player_hp.hp = i32::min(player_hp.hp + 1, player_hp.max_hp);
+    }
+
+    RunState::PlayerTurn
+}
+
+pub fn player_input(gs: &mut State, ctx: &mut Rltk) -> RunState {
+    // Player movement
+    match ctx.key {
+        None => { return RunState::AwaitingInput } // Nothing happened
+        Some(key) => match key {
+            VirtualKeyCode::Left => try_move_player(-1, 0, &mut gs.ecs),
+            VirtualKeyCode::Numpad4 => try_move_player(-1, 0, &mut gs.ecs),
+            VirtualKeyCode::H => try_move_player(-1, 0, &mut gs.ecs),
+
+            VirtualKeyCode::Right => try_move_player(1, 0, &mut gs.ecs),
+            VirtualKeyCode::Numpad6 => try_move_player(1, 0, &mut gs.ecs),            
+            VirtualKeyCode::L => try_move_player(1, 0, &mut gs.ecs),
+
+            VirtualKeyCode::Up => try_move_player(0, -1, &mut gs.ecs),
+            VirtualKeyCode::Numpad8 => try_move_player(0, -1, &mut gs.ecs),
+            VirtualKeyCode::K => try_move_player(0, -1, &mut gs.ecs),
+
+            VirtualKeyCode::Down => try_move_player(0, 1, &mut gs.ecs),
+            VirtualKeyCode::Numpad2 => try_move_player(0, 1, &mut gs.ecs),
+            VirtualKeyCode::J => try_move_player(0, 1, &mut gs.ecs),
+
+            // Diagonals
+            VirtualKeyCode::Numpad9 => try_move_player(1, -1, &mut gs.ecs),
+            VirtualKeyCode::Y => try_move_player(1, -1, &mut gs.ecs),
+
+            VirtualKeyCode::Numpad7 => try_move_player(-1, -1, &mut gs.ecs),
+            VirtualKeyCode::U => try_move_player(-1, -1, &mut gs.ecs),
+
+            VirtualKeyCode::Numpad3 => try_move_player(1, 1, &mut gs.ecs),
+            VirtualKeyCode::N => try_move_player(1, 1, &mut gs.ecs),
+
+            VirtualKeyCode::Numpad1 => try_move_player(-1, 1, &mut gs.ecs),
+            VirtualKeyCode::B => try_move_player(-1, 1, &mut gs.ecs),
+
+            // Skip Turn
+            VirtualKeyCode::Numpad5 => return skip_turn(&mut gs.ecs),
+            VirtualKeyCode::Space => return skip_turn(&mut gs.ecs),
+
+            // Level changes
+            VirtualKeyCode::Period => {
+                if try_next_level(&mut gs.ecs) {
+                    return RunState::NextLevel;
+                }
+            }
+
+            // Picking up items
+            VirtualKeyCode::G => get_item(&mut gs.ecs),
+            VirtualKeyCode::I => return RunState::ShowInventory,
+            VirtualKeyCode::D => return RunState::ShowDropItem,
+            VirtualKeyCode::R => return RunState::ShowRemoveItem,
+
+            // Save and Quit
+            VirtualKeyCode::Escape => return RunState::SaveGame,
+
+            _ => { return RunState::AwaitingInput }
+        },
+    }
+    RunState::PlayerTurn
+}

+ 49 - 0
chapter-21-rexmenu/src/random_table.rs

@@ -0,0 +1,49 @@
+use rltk::RandomNumberGenerator;
+
+pub struct RandomEntry {
+    name : String,
+    weight : i32
+}
+
+impl RandomEntry {
+    pub fn new<S:ToString>(name: S, weight: i32) -> RandomEntry {
+        RandomEntry{ name: name.to_string(), weight }
+    }
+}
+
+#[derive(Default)]
+pub struct RandomTable {
+    entries : Vec<RandomEntry>,
+    total_weight : i32
+}
+
+impl RandomTable {
+    pub fn new() -> RandomTable {
+        RandomTable{ entries: Vec::new(), total_weight: 0 }
+    }
+
+    pub fn add<S:ToString>(mut self, name : S, weight: i32) -> RandomTable {
+        if weight > 0 {
+            self.total_weight += weight;
+            self.entries.push(RandomEntry::new(name.to_string(), weight));
+        }
+        self
+    }
+
+    pub fn roll(&self, rng : &mut RandomNumberGenerator) -> String {
+        if self.total_weight == 0 { return "None".to_string(); }
+        let mut roll = rng.roll_dice(1, self.total_weight)-1;
+        let mut index : usize = 0;
+
+        while roll > 0 {
+            if roll < self.entries[index].weight {
+                return self.entries[index].name.clone();
+            }
+
+            roll -= self.entries[index].weight;
+            index += 1;
+        }
+
+        "None".to_string()
+    }
+}

+ 24 - 0
chapter-21-rexmenu/src/rect.rs

@@ -0,0 +1,24 @@
+use serde::{Serialize, Deserialize};
+
+#[derive(PartialEq, Copy, Clone, Serialize, Deserialize)]
+pub struct Rect {
+    pub x1 : i32,
+    pub x2 : i32,
+    pub y1 : i32,
+    pub y2 : i32
+}
+
+impl Rect {
+    pub fn new(x:i32, y: i32, w:i32, h:i32) -> Rect {
+        Rect{x1:x, y1:y, x2:x+w, y2:y+h}
+    }
+
+    // Returns true if this overlaps with other
+    pub fn intersect(&self, other:&Rect) -> bool {
+        self.x1 <= other.x2 && self.x2 >= other.x1 && self.y1 <= other.y2 && self.y2 >= other.y1
+    }
+
+    pub fn center(&self) -> (i32, i32) {
+        ((self.x1 + self.x2)/2, (self.y1 + self.y2)/2)
+    }
+}

+ 14 - 0
chapter-21-rexmenu/src/rex_assets.rs

@@ -0,0 +1,14 @@
+use rltk::{rex::XpFile};
+use std::fs::File;
+
+pub struct RexAssets {
+    pub menu : XpFile
+}
+
+impl RexAssets {
+    pub fn new() -> RexAssets {
+        RexAssets{
+            menu : XpFile::read(&mut File::open("../resources/SmallDungeon_80x50.xp").unwrap()).unwrap()
+        }
+    }
+}

+ 119 - 0
chapter-21-rexmenu/src/saveload_system.rs

@@ -0,0 +1,119 @@
+use specs::prelude::*;
+use specs::saveload::{SimpleMarker, SimpleMarkerAllocator, SerializeComponents, DeserializeComponents, MarkedBuilder};
+use specs::error::NoError;
+use super::components::*;
+use std::fs::File;
+use std::path::Path;
+use std::fs;
+
+macro_rules! serialize_individually {
+    ($ecs:expr, $ser:expr, $data:expr, $( $type:ty),*) => {
+        $(
+        SerializeComponents::<NoError, SimpleMarker<SerializeMe>>::serialize(
+            &( $ecs.read_storage::<$type>(), ),
+            &$data.0,
+            &$data.1,
+            &mut $ser,
+        )
+        .unwrap();
+        )*
+    };
+}
+
+pub fn save_game(ecs : &mut World) {
+    // Create helper
+    let mapcopy = ecs.get_mut::<super::map::Map>().unwrap().clone();
+    let savehelper = ecs
+        .create_entity()
+        .with(SerializationHelper{ map : mapcopy })
+        .marked::<SimpleMarker<SerializeMe>>()
+        .build();
+
+    // Actually serialize
+    {
+        let data = ( ecs.entities(), ecs.read_storage::<SimpleMarker<SerializeMe>>() );
+
+        let writer = File::create("./savegame.json").unwrap();
+        let mut serializer = serde_json::Serializer::new(writer);
+        serialize_individually!(ecs, serializer, data, Position, Renderable, Player, Viewshed, Monster, 
+            Name, BlocksTile, CombatStats, SufferDamage, WantsToMelee, Item, Consumable, Ranged, InflictsDamage, 
+            AreaOfEffect, Confusion, ProvidesHealing, InBackpack, WantsToPickupItem, WantsToUseItem,
+            WantsToDropItem, SerializationHelper, Equippable, Equipped, MeleePowerBonus, DefenseBonus,
+            WantsToRemoveItem, ParticleLifetime, HungerClock, ProvidesFood, MagicMapper
+        );
+    }
+
+    // Clean up
+    ecs.delete_entity(savehelper).expect("Crash on cleanup");
+}
+
+pub fn does_save_exist() -> bool {
+    Path::new("./savegame.json").exists()
+}
+
+macro_rules! deserialize_individually {
+    ($ecs:expr, $de:expr, $data:expr, $( $type:ty),*) => {
+        $(
+        DeserializeComponents::<NoError, _>::deserialize(
+            &mut ( &mut $ecs.write_storage::<$type>(), ),
+            &mut $data.0, // entities
+            &mut $data.1, // marker
+            &mut $data.2, // allocater
+            &mut $de,
+        )
+        .unwrap();
+        )*
+    };
+}
+
+pub fn load_game(ecs: &mut World) {
+    {
+        // Delete everything
+        let mut to_delete = Vec::new();
+        for e in ecs.entities().join() {
+            to_delete.push(e);
+        }
+        for del in to_delete.iter() {
+            ecs.delete_entity(*del).expect("Deletion failed");
+        }
+    }
+
+    let data = fs::read_to_string("./savegame.json").unwrap();
+    let mut de = serde_json::Deserializer::from_str(&data);
+
+    {
+        let mut d = (&mut ecs.entities(), &mut ecs.write_storage::<SimpleMarker<SerializeMe>>(), &mut SimpleMarkerAllocator::<SerializeMe>::new());
+
+        deserialize_individually!(ecs, de, d, Position, Renderable, Player, Viewshed, Monster, 
+            Name, BlocksTile, CombatStats, SufferDamage, WantsToMelee, Item, Consumable, Ranged, InflictsDamage, 
+            AreaOfEffect, Confusion, ProvidesHealing, InBackpack, WantsToPickupItem, WantsToUseItem,
+            WantsToDropItem, SerializationHelper, Equippable, Equipped, MeleePowerBonus, DefenseBonus,
+            WantsToRemoveItem, ParticleLifetime, HungerClock, ProvidesFood, MagicMapper
+        );
+    }
+
+    let mut deleteme : Option<Entity> = None;
+    {
+        let entities = ecs.entities();
+        let helper = ecs.read_storage::<SerializationHelper>();
+        let player = ecs.read_storage::<Player>();
+        let position = ecs.read_storage::<Position>();
+        for (e,h) in (&entities, &helper).join() {
+            let mut worldmap = ecs.write_resource::<super::map::Map>();
+            *worldmap = h.map.clone();
+            worldmap.tile_content = vec![Vec::new(); super::map::MAPCOUNT];
+            deleteme = Some(e);
+        }
+        for (e,_p,pos) in (&entities, &player, &position).join() {
+            let mut ppos = ecs.write_resource::<rltk::Point>();
+            *ppos = rltk::Point::new(pos.x, pos.y);
+            let mut player_resource = ecs.write_resource::<Entity>();
+            *player_resource = e;
+        }
+    }
+    ecs.delete_entity(deleteme.unwrap()).expect("Unable to delete helper");
+}
+
+pub fn delete_save() {
+    if Path::new("./savegame.json").exists() { std::fs::remove_file("./savegame.json").expect("Unable to delete file"); } 
+}

+ 294 - 0
chapter-21-rexmenu/src/spawner.rs

@@ -0,0 +1,294 @@
+extern crate rltk;
+use rltk::{ RGB, RandomNumberGenerator };
+extern crate specs;
+use specs::prelude::*;
+use super::{CombatStats, Player, Renderable, Name, Position, Viewshed, Monster, BlocksTile, Rect, Item, 
+    Consumable, Ranged, ProvidesHealing, map::MAPWIDTH, InflictsDamage, AreaOfEffect, Confusion, SerializeMe,
+    random_table::RandomTable, EquipmentSlot, Equippable, MeleePowerBonus, DefenseBonus, HungerClock,
+    HungerState, ProvidesFood, MagicMapper };
+use crate::specs::saveload::{MarkedBuilder, SimpleMarker};
+use std::collections::HashMap;
+
+/// Spawns the player and returns his/her entity object.
+pub fn player(ecs : &mut World, player_x : i32, player_y : i32) -> Entity {
+    ecs
+        .create_entity()
+        .with(Position { x: player_x, y: player_y })
+        .with(Renderable {
+            glyph: rltk::to_cp437('@'),
+            fg: RGB::named(rltk::YELLOW),
+            bg: RGB::named(rltk::BLACK),
+            render_order: 0
+        })
+        .with(Player{})
+        .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true })
+        .with(Name{name: "Player".to_string() })
+        .with(CombatStats{ max_hp: 30, hp: 30, defense: 2, power: 5 })
+        .with(HungerClock{ state: HungerState::WellFed, duration: 20 })
+        .marked::<SimpleMarker<SerializeMe>>()
+        .build()
+}
+
+const MAX_MONSTERS : i32 = 4;
+
+fn room_table(map_depth: i32) -> RandomTable {
+    RandomTable::new()
+        .add("Goblin", 10)
+        .add("Orc", 1 + map_depth)
+        .add("Health Potion", 7)
+        .add("Fireball Scroll", 2 + map_depth)
+        .add("Confusion Scroll", 2 + map_depth)
+        .add("Magic Missile Scroll", 4)
+        .add("Dagger", 3)
+        .add("Shield", 3)
+        .add("Longsword", map_depth - 1)
+        .add("Tower Shield", map_depth - 1)
+        .add("Rations", 10)
+        .add("Magic Mapping Scroll", 2)
+}
+
+/// Fills a room with stuff!
+#[allow(clippy::map_entry)]
+pub fn spawn_room(ecs: &mut World, room : &Rect, map_depth: i32) {
+    let spawn_table = room_table(map_depth);
+    let mut spawn_points : HashMap<usize, String> = HashMap::new();
+
+    // Scope to keep the borrow checker happy
+    {
+        let mut rng = ecs.write_resource::<RandomNumberGenerator>();
+        let num_spawns = rng.roll_dice(1, MAX_MONSTERS + 3) + (map_depth - 1) - 3;
+
+        for _i in 0 .. num_spawns {
+            let mut added = false;
+            let mut tries = 0;
+            while !added && tries < 20 {
+                let x = (room.x1 + rng.roll_dice(1, i32::abs(room.x2 - room.x1))) as usize;
+                let y = (room.y1 + rng.roll_dice(1, i32::abs(room.y2 - room.y1))) as usize;
+                let idx = (y * MAPWIDTH) + x;
+                if !spawn_points.contains_key(&idx) {
+                    spawn_points.insert(idx, spawn_table.roll(&mut rng));
+                    added = true;
+                } else {
+                    tries += 1;
+                }
+            }
+        }
+    }
+
+    // Actually spawn the monsters
+    for spawn in spawn_points.iter() {
+        let x = (*spawn.0 % MAPWIDTH) as i32;
+        let y = (*spawn.0 / MAPWIDTH) as i32;
+
+        match spawn.1.as_ref() {
+            "Goblin" => goblin(ecs, x, y),
+            "Orc" => orc(ecs, x, y),
+            "Health Potion" => health_potion(ecs, x, y),
+            "Fireball Scroll" => fireball_scroll(ecs, x, y),
+            "Confusion Scroll" => confusion_scroll(ecs, x, y),
+            "Magic Missile Scroll" => magic_missile_scroll(ecs, x, y),
+            "Dagger" => dagger(ecs, x, y),
+            "Shield" => shield(ecs, x, y),
+            "Longsword" => longsword(ecs, x, y),
+            "Tower Shield" => tower_shield(ecs, x, y),
+            "Rations" => rations(ecs, x, y),
+            "Magic Mapping Scroll" => magic_mapping_scroll(ecs, x, y),
+            _ => {}
+        }
+    }
+}
+
+fn orc(ecs: &mut World, x: i32, y: i32) { monster(ecs, x, y, rltk::to_cp437('o'), "Orc"); }
+fn goblin(ecs: &mut World, x: i32, y: i32) { monster(ecs, x, y, rltk::to_cp437('g'), "Goblin"); }
+
+fn monster<S : ToString>(ecs: &mut World, x: i32, y: i32, glyph : u8, name : S) {
+    ecs.create_entity()
+        .with(Position{ x, y })
+        .with(Renderable{
+            glyph,
+            fg: RGB::named(rltk::RED),
+            bg: RGB::named(rltk::BLACK),
+            render_order: 1
+        })
+        .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true })
+        .with(Monster{})
+        .with(Name{ name : name.to_string() })
+        .with(BlocksTile{})
+        .with(CombatStats{ max_hp: 16, hp: 16, defense: 1, power: 4 })
+        .marked::<SimpleMarker<SerializeMe>>()
+        .build();
+}
+
+fn health_potion(ecs: &mut World, x: i32, y: i32) {
+    ecs.create_entity()
+        .with(Position{ x, y })
+        .with(Renderable{
+            glyph: rltk::to_cp437('¡'),
+            fg: RGB::named(rltk::MAGENTA),
+            bg: RGB::named(rltk::BLACK),
+            render_order: 2
+        })
+        .with(Name{ name : "Health Potion".to_string() })
+        .with(Item{})
+        .with(Consumable{})
+        .with(ProvidesHealing{ heal_amount: 8 })
+        .marked::<SimpleMarker<SerializeMe>>()
+        .build();
+}
+
+fn magic_missile_scroll(ecs: &mut World, x: i32, y: i32) {
+    ecs.create_entity()
+        .with(Position{ x, y })
+        .with(Renderable{
+            glyph: rltk::to_cp437(')'),
+            fg: RGB::named(rltk::CYAN),
+            bg: RGB::named(rltk::BLACK),
+            render_order: 2
+        })
+        .with(Name{ name : "Magic Missile Scroll".to_string() })
+        .with(Item{})
+        .with(Consumable{})
+        .with(Ranged{ range: 6 })
+        .with(InflictsDamage{ damage: 20 })
+        .marked::<SimpleMarker<SerializeMe>>()
+        .build();
+}
+
+fn fireball_scroll(ecs: &mut World, x: i32, y: i32) {
+    ecs.create_entity()
+        .with(Position{ x, y })
+        .with(Renderable{
+            glyph: rltk::to_cp437(')'),
+            fg: RGB::named(rltk::ORANGE),
+            bg: RGB::named(rltk::BLACK),
+            render_order: 2
+        })
+        .with(Name{ name : "Fireball Scroll".to_string() })
+        .with(Item{})
+        .with(Consumable{})
+        .with(Ranged{ range: 6 })
+        .with(InflictsDamage{ damage: 20 })
+        .with(AreaOfEffect{ radius: 3 })
+        .marked::<SimpleMarker<SerializeMe>>()
+        .build();
+}
+
+fn confusion_scroll(ecs: &mut World, x: i32, y: i32) {
+    ecs.create_entity()
+        .with(Position{ x, y })
+        .with(Renderable{
+            glyph: rltk::to_cp437(')'),
+            fg: RGB::named(rltk::PINK),
+            bg: RGB::named(rltk::BLACK),
+            render_order: 2
+        })
+        .with(Name{ name : "Confusion Scroll".to_string() })
+        .with(Item{})
+        .with(Consumable{})
+        .with(Ranged{ range: 6 })
+        .with(Confusion{ turns: 4 })
+        .marked::<SimpleMarker<SerializeMe>>()
+        .build();
+}
+
+fn dagger(ecs: &mut World, x: i32, y: i32) {
+    ecs.create_entity()
+        .with(Position{ x, y })
+        .with(Renderable{
+            glyph: rltk::to_cp437('/'),
+            fg: RGB::named(rltk::CYAN),
+            bg: RGB::named(rltk::BLACK),
+            render_order: 2
+        })
+        .with(Name{ name : "Dagger".to_string() })
+        .with(Item{})
+        .with(Equippable{ slot: EquipmentSlot::Melee })
+        .with(MeleePowerBonus{ power: 2 })
+        .marked::<SimpleMarker<SerializeMe>>()
+        .build();
+}
+
+fn shield(ecs: &mut World, x: i32, y: i32) {
+    ecs.create_entity()
+        .with(Position{ x, y })
+        .with(Renderable{
+            glyph: rltk::to_cp437('('),
+            fg: RGB::named(rltk::CYAN),
+            bg: RGB::named(rltk::BLACK),
+            render_order: 2
+        })
+        .with(Name{ name : "Shield".to_string() })
+        .with(Item{})
+        .with(Equippable{ slot: EquipmentSlot::Shield })
+        .with(DefenseBonus{ defense: 1 })
+        .marked::<SimpleMarker<SerializeMe>>()
+        .build();
+}
+
+fn longsword(ecs: &mut World, x: i32, y: i32) {
+    ecs.create_entity()
+        .with(Position{ x, y })
+        .with(Renderable{
+            glyph: rltk::to_cp437('/'),
+            fg: RGB::named(rltk::YELLOW),
+            bg: RGB::named(rltk::BLACK),
+            render_order: 2
+        })
+        .with(Name{ name : "Longsword".to_string() })
+        .with(Item{})
+        .with(Equippable{ slot: EquipmentSlot::Melee })
+        .with(MeleePowerBonus{ power: 4 })
+        .marked::<SimpleMarker<SerializeMe>>()
+        .build();
+}
+
+fn tower_shield(ecs: &mut World, x: i32, y: i32) {
+    ecs.create_entity()
+        .with(Position{ x, y })
+        .with(Renderable{
+            glyph: rltk::to_cp437('('),
+            fg: RGB::named(rltk::YELLOW),
+            bg: RGB::named(rltk::BLACK),
+            render_order: 2
+        })
+        .with(Name{ name : "Tower Shield".to_string() })
+        .with(Item{})
+        .with(Equippable{ slot: EquipmentSlot::Shield })
+        .with(DefenseBonus{ defense: 3 })
+        .marked::<SimpleMarker<SerializeMe>>()
+        .build();
+}
+
+fn rations(ecs: &mut World, x: i32, y: i32) {
+    ecs.create_entity()
+        .with(Position{ x, y })
+        .with(Renderable{
+            glyph: rltk::to_cp437('%'),
+            fg: RGB::named(rltk::GREEN),
+            bg: RGB::named(rltk::BLACK),
+            render_order: 2
+        })
+        .with(Name{ name : "Rations".to_string() })
+        .with(Item{})
+        .with(ProvidesFood{})
+        .with(Consumable{})
+        .marked::<SimpleMarker<SerializeMe>>()
+        .build();
+}
+
+fn magic_mapping_scroll(ecs: &mut World, x: i32, y: i32) {
+    ecs.create_entity()
+        .with(Position{ x, y })
+        .with(Renderable{
+            glyph: rltk::to_cp437(')'),
+            fg: RGB::named(rltk::CYAN3),
+            bg: RGB::named(rltk::BLACK),
+            render_order: 2
+        })
+        .with(Name{ name : "Scroll of Magic Mapping".to_string() })
+        .with(Item{})
+        .with(MagicMapper{})
+        .with(Consumable{})
+        .marked::<SimpleMarker<SerializeMe>>()
+        .build();
+}

+ 37 - 0
chapter-21-rexmenu/src/visibility_system.rs

@@ -0,0 +1,37 @@
+extern crate specs;
+use specs::prelude::*;
+use super::{Viewshed, Position, Map, Player};
+extern crate rltk;
+use rltk::{field_of_view, Point};
+
+pub struct VisibilitySystem {}
+
+impl<'a> System<'a> for VisibilitySystem {
+    type SystemData = ( WriteExpect<'a, Map>,
+                        Entities<'a>,
+                        WriteStorage<'a, Viewshed>, 
+                        ReadStorage<'a, Position>,
+                        ReadStorage<'a, Player>);
+
+    fn run(&mut self, data : Self::SystemData) {
+        let (mut map, entities, mut viewshed, pos, player) = data;
+
+        for (ent,viewshed,pos) in (&entities, &mut viewshed, &pos).join() {
+            if viewshed.dirty {
+                viewshed.dirty = false;
+                viewshed.visible_tiles = field_of_view(Point::new(pos.x, pos.y), viewshed.range, &*map);
+
+                // If this is the player, reveal what they can see
+                let _p : Option<&Player> = player.get(ent);
+                if let Some(_p) = _p {
+                    for t in map.visible_tiles.iter_mut() { *t = false };
+                    for vis in viewshed.visible_tiles.iter() {
+                        let idx = map.xy_idx(vis.x, vis.y);
+                        map.revealed_tiles[idx] = true;
+                        map.visible_tiles[idx] = true;
+                    }
+                }
+            }
+        }
+    }
+}

BIN
resources/SmallDungeon_80x50.xp