We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
11. More Interesting Dungeons
Today we start to build a way to make the game more dynamic. We already have a way to build a somewhat random map, buts it always looks about the same and there is no true randomness. We will build out different maps that will have a theme. We will build this using traits. Let’s get started.
Creating Traits
We will be making our own trait for the map builder. Traits provide and interface and describe functions that a trait consumer must implement. They will also specify constraints to ensure that types that implement your trait are compatible.
The first step is to divide map_builder into a directory-based module that supports sub-modules.
Dividing the Map Builder
We will build the different traits into a sub folder that way we can try and call on the module that we want based on the type of map that we want. We will do this in 3 steps:
1. Create a new directory with the same name as the existing module, but
do not include the file extension. Create a directory named src/map_builder.
2. In the new directory, create a file named mod.rs, and copy the contents of
your existing module (map_builder.rs) into the new mod.rs file.
3. Delete the old map_builder.rs file.
There will now be a lot of files that are freaking out about missing content we will deal with that and build more traits as we go forward.
Offering Map Services with MapBuilder
MapArchitect provide a MapBuilder. This will allow us to put some common code into MapBuilder. We will first need to implement a common version of fill and find_most_distant. Open up the new mod.rs and add the following:
fn fill(&mut self, tile: TileType) {
self.map.tiles.iter_mut().for_each(|t| *t = tile);
}
fn find_most_distant(&self) -> Point {
let dijkstra_map = DijkstraMap::new(
CONSOLE_WIDTH,
CONSOLE_HEIGHT,
&vec![self.map.point2d_to_index(self.player_start)],
&self.map,
1024.0,
);
const UNREACHABLE: &f32 = &f32::MAX;
self.map.index_to_point2d(
dijkstra_map
.map
.iter()
.enumerate()
.filter(|(_, dist)| *dist < UNREACHABLE)
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
.unwrap()
.0,
)
}
We took some of the same code as before an created a function for it so we can use it at any time.
Defining the Architect Trait
Here is where we will define a trait for the map_builder at the top under the use input this code:
trait MapArchitect {
fn new(&mut self, rng: &mut RandomNumberGenerator) -> MapBuilder;
}
trait is very similar to the struct def if you need it to be exported add the pub to the start, the inside is the signature of the trait thus only put what you want ALL function to have to use. This will take a RandomNumberGenerator and return a map_builder that is ready to use.
Test Your Trait with a Reference Implementation
We can now build a Reference that will do the minimum required to use a trait. In this case we need a random number generator and it should return a MapBuilder. Create a new file in the map_builder folder named empty and fill it with this:
use super::MapArchitect;
use crate::prelude::*;
pub struct EmptyArchitect {}
impl MapArchitect for EmptyArchitect {
fn new(&mut self, rng: &mut RandomNumberGenerator) -> MapBuilder {
let mut mb = MapBuilder {
map: Map::new(),
rooms: Vec::new(),
monster_spawns: Vec::new(),
player_start: Point::zero(),
amulet_start: Point::zero(),
};
mb.fill(TileType::Floor);
mb.player_start = Point::new(CONSOLE_WIDTH / 2, CONSOLE_HEIGHT / 2);
mb.amulet_start = mb.find_most_distant();
for _ in 0..50 {
mb.monster_spawns.push(Point::new(
rng.range(1, CONSOLE_WIDTH),
rng.range(1, CONSOLE_HEIGHT),
))
}
mb
}
}
super import from the immediate parent, define and empty struct, now we can define the implementation for the MapArchitect trait, new() must exactly match the new we defined for the trait, the rest is boiler plate just to build a random map.
Last thing we need to do is add in the monster_spawns to the mod.rs for the MapBuilder struct.
pub struct MapBuilder {
pub map: Map,
pub rooms: Vec<Rect>,
pub player_start: Point,
pub monster_spawns: Vec<Point>,
pub amulet_start: Point,
}
Yes we still have some broken code but we can now use the new trait.
Calling the Empty-Map Architect
Let’s change how we build the new for the map. Be sure to add all the following code to map_builder/mod.rs
mod empty;
...
use empty::EmptyArchitect;
...
impl MapBuilder {
pub fn new(rng: &mut RandomNumberGenerator) -> Self {
let mut architect = EmptyArchitect{};
architect.new(rng)
}
...
}
We are now using the new EmptyArchitect trait. We just need to update main.rs to spawn monsters.
impl State {
fn new() -> Self {
...
spawn_amulet_of_yala(&mut ecs, map_builder.amulet_start);
map_builder
.monster_spawns
.iter()
.for_each(|pos| spawn_monster(&mut ecs, &mut rng, *pos));
resources.insert(map_builder.map);
...
}
// Now the reset as well.
fn reset_game_state(&mut self) {
...
spawn_amulet_of_yala(&mut self.ecs, map_builder.amulet_start);
map_builder
.monster_spawns
.iter()
.for_each(|pos| spawn_monster(&mut self.ecs, &mut rng, *pos));
...
}
...
Run the big empty room game now.
Converting the Room Builder
Okay so now we can build off of what we have and make a RoomsArchitect, create a new file named map_builder/rooms.rs and fill it with this:
use super::MapArchitect;
use crate::prelude::*;
pub struct RoomsArchitect {}
impl MapArchitect for RoomsArchitect {
fn new(&mut self, rng: &mut RandomNumberGenerator) -> MapBuilder {
let mut mb = MapBuilder {
map: Map::new(),
rooms: Vec::new(),
monster_spawns: Vec::new(),
player_start: Point::zero(),
amulet_start: Point::zero(),
};
mb.fill(TileType::Wall);
mb.build_random_rooms(rng);
mb.build_corridors(rng);
mb.player_start = mb.rooms[0].center();
mb.amulet_start = mb.find_most_distant();
for room in mb.rooms.iter().skip(1) {
mb.monster_spawns.push(room.center());
}
mb
}
}
Now we can open up the mod.rs in map_builder and add in the new trait (don’t forget about mod and use):
mod rooms;
...
use rooms:RoomsArchitect;
impl MapBuilder {
pub fn new(rng: &mut RandomNumberGenerator) -> Self {
let mut architect = RoomsArchitect {};
architect.new(rng)
}
...
}
Okay we now have the basic version of the map generator. Let’s build something better.
Creating Cellular Automata Maps
This will be how we make good looking maps. It will slowly create order from chaos.
Cellular Automata Theory
This is going to simulate organic growth by asking the same question over and over, how many neighbors are walls? It x are walls then become a wall if y are walls become an empty space.
Implement Cellular Automata
For this to work we need to have a new file map_builder/automata.rs and then we need to create some noise to then filter into something that works.
use super::MapArchitect;
use crate::prelude::*;
pub struct CellularAutomataArchitect {}
impl MapArchitect for CellularAutomataArchitect {
fn new(&mut self, rng: &mut RandomNumberGenerator) -> MapBuilder {
let mut mb = MapBuilder {
map: Map::new(),
rooms: Vec::new(),
monster_spawns: Vec::new(),
player_start: Point::zero(),
amulet_start: Point::zero(),
};
mb
}
}
This is just an empty map but we can build from here. Let’s create some noise.
Make Some Random Noise
Now we can create a map full of random walls that we can then filter out. Add this function as the impl for CellularAutomataArchitect:
impl CellularAutomataArchitect {
fn random_noise_map(&mut self, rng: &mut RandomNumberGenerator, map: &mut Map) {
map.tiles.iter_mut().for_each(|t| {
let roll = rng.range(0, 100);
if roll > 55 {
*t = TileType::Floor;
} else {
*t = TileType::Wall;
}
});
}
}
If you were to run the game from here it would be just a map with random walls but we can bring order to this chaos.
Counting Neighbors
Let’s add in the ability to count the neighbors of a cell.
impl CellularAutomataArchitect {
...
fn count_neighbors(&self, x: i32, y: i32, map: &Map) -> usize {
let mut neighbors = 0;
for iy in -1..=1 {
for ix in -1..=1 {
if !(ix == 0 && iy == 0) && map.tiles[map_idx(x + ix, y + iy)] == TileType::Wall {
neighbors += 1
}
}
}
neighbors
}
}
Okay so we can count the neighbors lets do something with it.
Iterating Away the Chaos
Okay so now we can do something point by point in order to make the map usable. Let’s create an other function in the implementation.
fn iteration(&mut self, map: &mut Map) {
let mut new_tiles = map.tiles.clone();
for y in 1..CONSOLE_HEIGHT - 1 {
for x in 1..CONSOLE_WIDTH - 1 {
let neighbors = self.count_neighbors(x, y, map);
let idx = map_idx(x, y);
if neighbors > 4 || neighbors == 0 {
new_tiles[idx] = TileType::Wall;
} else {
new_tiles[idx] = TileType::Floor;
}
}
}
map.tiles = new_tiles
}
Okay so we have a way to make more room out of the chaos, we now need to place monsters and the player. We no longer have rooms so much as chaos, we will need to find a way to place those entities.
Place the Player
We don’t have a room so let’s find the closest point to the center and place the player there. Add in a new function for the impl.
fn find_start(&self, map: &Map) -> Point {
let center = Point::new(CONSOLE_WIDTH / 2, CONSOLE_HEIGHT / 2);
let closest_point = map
.tiles
.iter()
.enumerate()
.filter(|(_, t)| **t == TileType::Floor)
.map(|(idx, _)| {
(
idx,
DistanceAlg::Pythagoras.distance2d(center, map.index_to_point2d(idx)),
)
})
.min_by(|(_, distance), (_, distance2)| distance.partial_cmp(&distance2).unwrap())
.map(|(idx, _)| idx)
.unwrap();
map.index_to_point2d(closest_point)
}
We find the center based off the CONSOLE dimensions, then we find the closest_point, we take all the tiles, then filter to just floor, then map the tiles to a distance from the center, then sort by min, then map to an idx then map that to a point.
Spawning Monsters without Rooms
Now we need to work on spawning monsters without rooms as well let’s add in an other function for that too. We don’t need to worry too much about rooms perse so much as we want them to spawn further away from the player. We can use the distance to our advantage. This will be built within the mod.rs
fn spawn_monsters(&self, start: &Point, rng: &mut RandomNumberGenerator) -> Vec<Point> {
const NUM_MONSTERS: usize = 50;
let mut spawnable_tiles: Vec<Point> = self
.map
.tiles
.iter()
.enumerate()
.filter(|(idx, t)| {
**t == TileType::Floor
&& DistanceAlg::Pythagoras.distance2d(*start, self.map.index_to_point2d(*idx))
> 10.0
})
.map(|(idx, _)| self.map.index_to_point2d(idx))
.collect();
let mut spawns = Vec::new();
for _ in 0..NUM_MONSTERS {
let target_index = rng.random_slice_index(&spawnable_tiles).unwrap();
spawns.push(spawnable_tiles[target_index].clone());
spawnable_tiles.remove(target_index);
}
spawns
}
We now have a way to insure that the monsters spawn at least 10 tile away from the player.
Build the Map
Let’s put all this to work. Let’s head back to the build function for the CellularAutomataArchitect and use the new functions.
impl MapArchitect for CellularAutomataArchitect {
fn new(&mut self, rng: &mut RandomNumberGenerator) -> MapBuilder {
let mut mb = MapBuilder {
map: Map::new(),
rooms: Vec::new(),
monster_spawns: Vec::new(),
player_start: Point::zero(),
amulet_start: Point::zero(),
};
self.random_noise_map(rng, &mut mb.map);
for _ in 0..10 {
self.iteration(&mut mb.map)
}
let start = self.find_start(&mb.map);
mb.monster_spawns = mb.spawn_monsters(&start, rng);
mb.player_start = start;
mb.amulet_start = mb.find_most_distant();
mb
}
}
Okay so we now have the trait for this dungeon builder. What is the next step?
Call the Cellular Automata Architect
impl MapBuilder {
pub fn new(rng: &mut RandomNumberGenerator) -> Self {
let mut architect = CellularAutomataArchitect {};
architect.new(rng)
}
...
}
Run it and see how it looks.
Creating Drunkard’s Walk Maps
This is an other way of creating the map, we first build a full map of walls and then let a drunken miner walk from place to place carving out floor tiles. This will need to be iterated a few times as the miner will go back to previous tiles but it creates a more “natural.”
Implementing the Boilerplate
Same as before let’s create the a new module map_builder/drunkard.rs then add the new files to the mod.rs:
use super::MapArchitect;
use crate::prelude::*;
pub struct DrunkardsWalkArchitect {}
impl MapArchitect for DrunkardsWalkArchitect {
fn new(&mut self, rng: &mut RandomNumberGenerator) -> MapBuilder {
let mut mb = MapBuilder {
map: Map::new(),
rooms: Vec::new(),
monster_spawns: Vec::new(),
player_start: Point::zero(),
amulet_start: Point::zero(),
};
}
}
// mod.rs
mod drunkard;
use drunkard::DrunkardsWalkArchitect;
Carve Caverns with Drunken Miners
We first need to figure out the right values for how long a miner will stumble first let’s add a constant at the top for this. Then implement the stumble.
const STAGGER_DISTANCE: usize = 400;
// drunkard
impl DrunkardsWalkArchitect {
fn drunkard(&mut self, start: &Point, rng: &mut RandomNumberGenerator, map: &mut Map) {
let mut drunkard_pos = start.clone(); // (8)
let mut distance_staggered = 0; // (9)
loop {
// (10)
let drunk_idx = map.point2d_to_index(drunkard_pos);
map.tiles[drunk_idx] = TileType::Floor; // (11)
match rng.range(0, 4) {
// (12)
0 => drunkard_pos.x -= 1,
1 => drunkard_pos.x += 1,
2 => drunkard_pos.y -= 1,
_ => drunkard_pos.y += 1,
}
if !map.in_bounds(drunkard_pos) {
// (13)
break;
}
distance_staggered += 1; // (14)
if distance_staggered > STAGGER_DISTANCE {
break;
}
}
}
}
This will start a miner with distance traveled at 0 to the start, then it will loop until the miner is out of bounds (or the stagger distance is greater than 400). This is all well and good but when is the map ready to play?
Determining Map Completeness
So for this we know that the drunken miner will start from the center thus all the rooms will be open. So it might be just as easy to just say that when we hit x% of the map is Floor we are good to go. Let’s set and other couple constants for that.
const NUM_TILES: usize = (CONSOLE_HEIGHT * CONSOLE_WIDTH) as usize;
const DESIRED_FLOOR: usize = NUM_TILES / 3;
Keep Digging until It’s Done
Now we can set up the builder to work as we need.
impl MapArchitect for DrunkardsWalkArchitect {
fn new(&mut self, rng: &mut RandomNumberGenerator) -> MapBuilder {
let mut mb = MapBuilder {
map: Map::new(),
rooms: Vec::new(),
monster_spawns: Vec::new(),
player_start: Point::zero(),
amulet_start: Point::zero(),
};
mb.fill(TileType::Wall);
let center = Point::new(CONSOLE_WIDTH / 2, CONSOLE_HEIGHT / 2);
self.drunkard(¢er, rng, &mut mb.map);
while mb
.map
.tiles
.iter()
.filter(|t| **t == TileType::Floor)
.count()
< DESIRED_FLOOR
// (1)
{
self.drunkard(
&Point::new(rng.range(0, CONSOLE_WIDTH), rng.range(0, CONSOLE_HEIGHT)),
rng,
&mut mb.map,
); // (2)
let dijkstra_map = DijkstraMap::new(
// (3)
CONSOLE_WIDTH,
CONSOLE_HEIGHT,
&vec![mb.map.point2d_to_index(center)],
&mb.map,
1024.0,
);
dijkstra_map
.map // (4)
.iter()
.enumerate() // (5)
.filter(|(_, distance)| *distance > &2000.0) // (6)
.for_each(|(idx, _)| mb.map.tiles[idx] = TileType::Wall); // (7)
}
mb.monster_spawns = mb.spawn_monsters(¢er, rng);
mb.player_start = center;
mb.amulet_start = mb.find_most_distant();
mb
}
}
We have the fill that will fill the map with Walls, then we find the center, we then use a while loop that will filter to just tiles that are floors, create the dijkstra map, everything that is 2000 spaces away from the center will be walls.
Activating the New Map Type
Now go back into the mod.rs and use the new map type.
impl MapBuilder {
pub fn new(rng: &mut RandomNumberGenerator) -> Self {
let mut architect = DrunkardsWalkArchitect {};
architect.new(rng)
}
...
}
Run the Game and see how it looks.
Randomly Selecting an Architect
Replace the code to spawn a new map with the following in mod.rs
pub fn new(rng: &mut RandomNumberGenerator) -> Self {
let mut architect: Box<dyn MapArchitect> = match rng.range(0, 3) {
0 => Box::new(DrunkardsWalkArchitect {}),
1 => Box::new(RoomsArchitect {}),
_ => Box::new(CellularAutomataArchitect {}),
};
let mut mb = architect.new(rng);
mb
}
One thing of note here is the Box, when you might have many different types that might be implemented you will want to reach for a Box as it is a smart pointer and Rust won’t yell at you for using it and not getting all the types of impl that you should need.
Prefabricating Map Sections
Now we can try and make sure that there are certain elements within our map. This will ensure that we can always have a boss or something like it. Let’s work on a few hand-made dungeons.
Handmade Dungeons
Let’s create a new file named map_builder/prefab.rs and then fill it in with the normal at the top and then this bit of code:
use crate::prelude::*;
const FORTRESS: (&str, i32, i32) = (
"
------------
---######---
---#----#---
---#-M--#---
-###----###-
--M------M--
-###----###-
---#----#---
---#----#---
---######---
------------
",
12,
11,
);
Unless you wanted to place your fortress on a map that is entirely hand made you will want to try and place it in an existing map, and if it doesn’t fit go without. We will build that below.
Placing Your Vault
pub fn apply_prefab(mb: &mut MapBuilder, rng: &mut RandomNumberGenerator) {
let mut placement = None;
let dijkstra_map = DijkstraMap::new(
CONSOLE_WIDTH,
CONSOLE_HEIGHT,
&vec![mb.map.point2d_to_index(mb.player_start)],
&mb.map,
1024.0,
);
let mut attempts = 0; // (1)
while placement.is_none() && attempts < 10 {
// (2)
let dimensions = Rect::with_size(
// (3)
rng.range(0, CONSOLE_WIDTH - FORTRESS.1),
rng.range(0, CONSOLE_HEIGHT - FORTRESS.2),
FORTRESS.1,
FORTRESS.2,
);
let mut can_place = false; // (4)
dimensions.for_each(|pt| {
// (5)
let idx = mb.map.point2d_to_index(pt);
let distance = dijkstra_map.map[idx];
if distance < 2000.0 && distance > 20.0 && mb.amulet_start != pt {
// (6)
can_place = true;
}
});
if can_place {
// (7)
placement = Some(Point::new(dimensions.x1, dimensions.y1));
let points = dimensions.point_set();
mb.monster_spawns.retain(|pt| !points.contains(pt)); // (8)
}
attempts += 1;
}
if let Some(placement) = placement {
// (9)
let string_vec: Vec<char> = FORTRESS
.0
.chars()
.filter(|a| *a != '\r' && *a != '\n')
.collect(); // (10)
let mut i = 0; // (11)
for ty in placement.y..placement.y + FORTRESS.2 {
// (12)
for tx in placement.x..placement.x + FORTRESS.1 {
let idx = map_idx(tx, ty);
let c = string_vec[i]; // (13)
match c {
// (14)
'M' => {
// (15)
mb.map.tiles[idx] = TileType::Floor;
mb.monster_spawns.push(Point::new(tx, ty));
}
'-' => mb.map.tiles[idx] = TileType::Floor, // (16)
'#' => mb.map.tiles[idx] = TileType::Wall,
_ => println!("No idea what to do with [{}]", c), // (17)
}
i += 1;
}
}
}
}
(1-8)
We will have a set amount of attempts for the placement, none and some are holders for something that happened and not happening, we set the dimensions, set the place to false, iterate through every tile, if the position is fine the set can_place to true, remove monsters from the location if there is any to begin with.
(9-17)
If we have a valid placement, remove the /n from the string and then collect, have a index for the current position in the “vault” string, iterate through the string, get the character at the index, match the current tile to a pattern match, set values will give set results, add a catch all for characters that we don’t know.
Okay so now we need to add the new functionality to the random map chooser. mod.rs
impl MapBuilder {
pub fn new(rng: &mut RandomNumberGenerator) -> Self {
let mut architect: Box<dyn MapArchitect> = match rng.range(0, 3) {
0 => Box::new(DrunkardsWalkArchitect {}),
1 => Box::new(RoomsArchitect {}),
_ => Box::new(CellularAutomataArchitect {}),
};
let mut mb = architect.new(rng);
apply_prefab(&mut mb, rng);
mb
}
...
}
Run the new game and see if you can find the fortress.
Wrap-Up
You did it you know have a way to have custom maps and even some prefab.