We have done already a lot of boiler plate code to setup the basic systems in this project. It is time to finally create something more fun and see the effects of our work. In this part we are going to start with procedural generation of the dungeons. (yaaay!)
The final code for this part can be found here: https://github.com/maciekglowka/hike_deck/tree/part_09
It is no secret that generating roguelike maps mean mostly placing rooms and connecting them with tunnels. That's what we are going to do here as well. However I'd like to also have a little bit of control over the higher level architecture of the dungeon. My aim here is to divide the board into a few distinctly featured parts to encourage player's exploration. I do not plan for the maps to be very complex or large though - just enough to spread places of interest quite apart.
I do not want to dive into very complex algorithms here as well - to keep things manageable (although I think we are going to need more than one blog post on that anyway). We are simply going to split the dungeon into a grid of sub areas, where each area will be a separately generated bunch of rooms. The areas might end up having unique features: Eg. one could be an area with an upgrade to find. Other might be a monster lair, etc. We are probably also going to use separate areas for the entrance and exits (to make sure they're not right next to each other).
The areas will be placed on an uneven rows/columns grid. (the order of the placement can be seen on the image above) We will make the code flexible enough to adjust the number of the rows, however we'll probably stick with just two for now. The column number will be decided dynamically - based on the areas inserted.
We are going to locate the dungeon generation code in the board
module, but as I expect it to grow in the future let's already create an entire directory submodule for that:
src/
├── actions
│ ├── mod.rs
│ ├── models.rs
│ └── systems.rs
├── assets.rs
├── board
│ ├── components.rs
│ ├── dungeon
│ │ ├── mod.rs
│ │ └── room.rs
│ ├── mod.rs
│ └── systems.rs
// cut
As you can see I've already placed two files there: the main mod.rs
and an extra one for the rooms. We are going to add some more soon, but for now let's focus on our Room
struct:
// board/dungeon/room.rs
use rand::prelude::*;
use std::collections::HashSet;
use crate::vectors::Vector2Int;
pub struct Room {
pub a: Vector2Int,
pub b: Vector2Int
}
impl Room {
pub fn new(a: Vector2Int, b: Vector2Int) -> Self {
// sort the coordinates so `a` is always the left-bottom
// and `b` is top-right
Room {
a: Vector2Int::new(a.x.min(b.x), a.y.min(b.y)),
b: Vector2Int::new(a.x.max(b.x), a.y.max(b.y))
}
}
pub fn corners(&self) -> [Vector2Int; 4] {
[
Vector2Int::new(self.a.x, self.a.y), Vector2Int::new(self.b.x, self.a.y),
Vector2Int::new(self.b.x, self.b.y), Vector2Int::new(self.a.x, self.b.y)
]
}
pub fn random_point(&self) -> Vector2Int {
let mut rng = thread_rng();
let x = rng.gen_range(self.a.x..=self.b.x);
let y = rng.gen_range(self.a.y..=self.b.y);
Vector2Int::new(x, y)
}
pub fn to_tiles(&self) -> HashSet<Vector2Int> {
(self.a.y..=self.b.y).map(|y| {
(self.a.x..=self.b.x).map(move |x| {
Vector2Int::new(x, y)
})
})
.flatten()
.collect()
}
}
The room is a simple struct, defined by two corners - the bottom-left a
and top-right b
. We also include some helper methods, that will become handy very soon.
Also note: we are going to use the rand
crate here, so don't forget to add it to your cargo dependencies. The current version I am using in the project is rand = "0.8.5"
.
Let's define struct for the areas in a similar fashion (add an area.rs
file):
// board/dungeon/area.rs
use std::collections::HashSet;
use crate::vectors::Vector2Int;
use super::room::Room;
pub struct Area {
pub rooms: Vec<Room>,
pub paths: Vec<Vec<Vector2Int>>
}
impl Area {
pub fn new() -> Self {
Area { rooms: Vec::new(), paths: Vec::new() }
}
pub fn get_bounds(&self) -> (Vector2Int, Vector2Int) {
let min_x = self.rooms.iter().map(|r| r.a.x).min().unwrap();
let max_x = self.rooms.iter().map(|r| r.b.x).max().unwrap();
let min_y = self.rooms.iter().map(|r| r.a.y).min().unwrap();
let max_y = self.rooms.iter().map(|r| r.b.y).max().unwrap();
(Vector2Int::new(min_x, min_y), Vector2Int::new(max_x, max_y))
}
pub fn get_size(&self) -> Vector2Int {
let bounds = self.get_bounds();
Vector2Int::new(bounds.1.x - bounds.0.x + 1, bounds.1.y - bounds.0.y + 1)
}
pub fn shift(&mut self, offset: Vector2Int) {
// translate the entire area by a given offset
let bounds = self.get_bounds();
let d = offset - bounds.0;
for room in self.rooms.iter_mut() {
room.a += d;
room.b += d;
}
for path in self.paths.iter_mut() {
for v in path.iter_mut() {
*v += d;
}
}
}
pub fn generate_rooms(&mut self) {
self.rooms = vec![
Room::new(Vector2Int::new(0, 0), Vector2Int::new(4, 6)),
Room::new(Vector2Int::new(10, 2), Vector2Int::new(14, 8))
]
}
pub fn to_tiles(&self) -> HashSet<Vector2Int> {
self.rooms.iter()
.map(|r| r.to_tiles())
.flatten()
.chain(
self.paths.iter().flatten().map(|v| *v)
)
.collect()
}
}
As you can see the definition is slightly more complex. First of all the area will consist of two fields (for now): a vec of rooms and a vec of paths. For the paths we are not going to define a separate struct - a vec of Vector2Ints should be ok for now.
The shift
function is going to let us reposition the areas within the dungeon grid. (as each area will be initially generated separately - in it's own space). The generate_rooms
method creates two hard-coded rooms for now - it will let us test the layouting before we start with more complex generation. The rest of the methods should be quite self-explanatory, so I will skip their description :)
Now we are ready to finally define the Dungeon
itself:
// board/dungeon/mod.rs
use std::collections::HashSet;
use crate::vectors::Vector2Int;
mod area;
mod room;
pub use area::Area;
const AREA_SPACING: i32 = 4;
pub struct Dungeon {
pub areas: Vec<Area>,
// the gird contains indexes to the areas vec
// rows / columns
grid: Vec<Vec<usize>>
}
impl Dungeon {
pub fn new(row_count: usize) -> Self {
let grid = (0..row_count).map(|_| Vec::new()).collect::<Vec<_>>();
Dungeon { areas: Vec::new(), grid }
}
pub fn add_area(&mut self, area: Area) {
self.areas.push(area);
let idx = self.areas.len() - 1;
let row_count = self.grid.len();
// insert the index to appropriate row vec
self.grid[idx % row_count].push(idx);
}
pub fn generate(&mut self) {
for area in self.areas.iter_mut() {
area.generate_rooms();
}
self.position_areas();
}
pub fn to_tiles(&self) -> HashSet<Vector2Int> {
self.areas.iter()
.map(|a| a.to_tiles())
.flatten()
.collect()
}
fn position_areas(&mut self) {
let column_count = self.grid[0].len();
// calculate grid dimensions based on contained areas
let column_widths = (0..column_count).map(|i|
self.grid.iter().map(|r| match r.get(i) {
None => 0,
Some(a) => self.areas[i].get_size().x
}).max().unwrap() + AREA_SPACING
)
.collect::<Vec<_>>();
let row_heights = self.grid.iter()
.map(|r|
r.iter().map(|i|
self.areas[*i].get_size().y
).max().unwrap() + AREA_SPACING
)
.collect::<Vec<_>>();
// calculate the offset amounts per each grid position
let column_shifts = (0..column_widths.len())
.map(|i| column_widths[..i].iter().sum())
.collect::<Vec<i32>>();
let row_shifts = (0..row_heights.len())
.map(|i| row_heights[..i].iter().sum())
.collect::<Vec<i32>>();
// reposition the areas
for (y, row) in self.grid.iter().enumerate() {
for (x, idx) in row.iter().enumerate() {
let offset = Vector2Int::new(column_shifts[x], row_shifts[y]);
self.areas[*idx].shift(offset);
}
}
}
}
That's a lot of code again! And not all of it is very readable (oops!). Let's try to break it down a bit.
Our Dungeon
struct is going to hold quite naturally a vec of Areas
. Apart from that we are going to keep a field defining our grid. It is a vec (rows) of vecs (columns) containing indexes to the areas
field. (we do not use references to make it easier for us to work with Rust's borrow checker ;) We are going to update the struct each time we insert a new area to the dungeon, so everything is properly synced. Please notice that because we chose an ordering system as on the image above, the first row will always be the longest one - so we can calculate the column count from it's length.
The position_areas
method uses lots of iterators and might not be the clearest to read. It is not very complex tough. At first, for each row and column we look for the area with the largest dimension. Then we calculate offsets per each grid x, y
position. Finally, we can shift the areas using that info.
The to_tiles
method allows us to convert the dungeon struct into our board tiles easily. It's based on analogous methods in the room and area structs.
Now we can go back to our spawn_map
system (from the very first tutorial part) and use the dungeon generator there:
// board/systems.rs
use bevy::prelude::*;
use std::collections::HashMap;
use super::CurrentBoard;
use super::dungeon::{Area, Dungeon};
use super::components::{Position, Tile};
pub fn spawn_map(
mut commands: Commands,
mut current: ResMut<CurrentBoard>
) {
let mut dungeon = Dungeon::new(2);
for _ in 0..4 {
dungeon.add_area(Area::new())
}
dungeon.generate();
current.tiles = HashMap::new();
for v in dungeon.to_tiles() {
let tile = commands.spawn((
Position { v },
Tile
))
.id();
current.tiles.insert(v, tile);
}
}
For the testing purposes we have inserted 4 separate areas to the dungeon. If you run the game now you should already be able to see the some rooms generated.
Now that we have some very basic rooms set up, we can try to connect them with tunnels. There are two very basic ways of generating corridors that come to my mind:
We are going to implement both to see how they can be easily used interchangeably to add some variation to our dungeons.
Let's start by creating a new tunneler.rs
file:
// board/dungeon/tunneler.rs
use rand::{
distributions::WeightedIndex,
prelude::*
};
use crate::vectors::Vector2Int;
pub trait Tunneler {
fn connect(&self, a: Vector2Int, b: Vector2Int) -> Vec<Vector2Int>;
}
pub struct LShapeTunneler;
impl Tunneler for LShapeTunneler {
// connects two points by forming an L shaped connection
// initial direction (hor / ver) is the one whith the biggest coordinate difference
fn connect(&self, a: Vector2Int, b: Vector2Int) -> Vec<Vector2Int> {
let d = b - a;
let (hor_y, ver_x) = match d.x > d.y {
true => (a.y, b.x),
false => (b.y, a.x)
};
let hor = (a.x.min(b.x)..=a.x.max(b.x))
.map(|x| Vector2Int::new(x, hor_y))
.collect::<Vec<_>>();
let ver = (a.y.min(b.y)..=a.y.max(b.y))
.map(|y| Vector2Int::new(ver_x, y))
.collect::<Vec<_>>();
[ver, hor].concat()
}
}
pub struct RandomTunneler;
impl Tunneler for RandomTunneler {
// connects two points by taking a random direction (hor / ver) towards the target
// choice chance is determined by a current coordinate difference
// (it is most likely to pick a dir with the biggest diff)
fn connect(&self, a: Vector2Int, b: Vector2Int) -> Vec<Vector2Int> {
let mut cur = a;
let mut path = Vec::new();
let mut rng = thread_rng();
while cur != b {
path.push(cur);
// 0 is horizontal, 1 is vertical
let dirs = vec![b.x - cur.x, b.y - cur.y];
// build weights
let dist = WeightedIndex::new(dirs.iter().map(|d| d.abs())).unwrap();
// pick a dir idx (0 or 1)
let dir_idx = dist.sample(&mut rng);
// create a normalized step vector in a single direction
let dv = match dir_idx {
0 => Vector2Int::new(dirs[0] / dirs[0].abs(), 0),
1 => Vector2Int::new(0, dirs[1] / dirs[1].abs()),
_ => panic!()
};
cur += dv;
}
path
}
}
As a first thing - we define a trait for our tunnelers, so they can share a common interface.
Then we create the two implementations mentioned above. The L-shaped tunneler creates just two sections, a horizontal and a vertical one. First one being the longest one.
The random tunneler with each step makes a decision about a direction to take. Based on a weighted random function. If we are further away on the x-axis from the target, we are more likely to pick the horizontal direction. Also we only walk towards our target. Eg. if we have to move 5 tiles right and 3 down, we can only draw positive x values and negative ys.
Let's now include the tunneler object in our Area
struct (so each area can have a different character):
pub struct Area {
pub rooms: Vec<Room>,
pub paths: Vec<Vec<Vector2Int>>,
pub tunneler: Box<dyn Tunneler>
}
impl Area {
pub fn new(tunneler: Box<dyn Tunneler>) -> Self {
Area { rooms: Vec::new(), paths: Vec::new(), tunneler }
}
// cut
Let's also change the dungeon generator code in the spawn_map
system to use both tunneler algorithms, so we can test them:
let mut dungeon = Dungeon::new(2);
for idx in 0..4 {
let tun = match idx % 2 {
0 => Box::new(tunneler::LShapeTunneler) as Box<dyn tunneler::Tunneler>,
_ => Box::new(tunneler::RandomTunneler) as Box<dyn tunneler::Tunneler>
};
dungeon.add_area(Area::new(tun))
}
dungeon.generate();
Now let's use our tunnelers to internally connect rooms in the areas:
// in board/dungeon/area.rs
impl Area {
// cut
pub fn join_rooms(&self, a: &Room, b: &Room) -> Vec<Vector2Int> {
self.tunneler.connect(a.random_point(), b.random_point())
}
pub fn generate_rooms(&mut self) {
self.rooms = vec![
Room::new(Vector2Int::new(0, 0), Vector2Int::new(4, 6)),
Room::new(Vector2Int::new(10, 2), Vector2Int::new(14, 8))
];
self.paths = vec![self.join_rooms(&self.rooms[0], &self.rooms[1])];
}
// cut
We can now run the game and check if our connections are working, but before we do that let's change temporarily the camera to see the entire map:
pub fn setup(mut commands: Commands) {
let mut camera = Camera2dBundle::default();
// temporary ;)
camera.projection.scale = 2.;
camera.transform.translation = Vec3::new(
20. * TILE_SIZE,
10. * TILE_SIZE,
camera.transform.translation.z
);
commands.spawn(camera);
}
There is one last thing that we are going to tackle in this part. We will use our tunnelers to connect the separated dungeon areas. We will do so by finding a closest pair of rooms between the areas we want to connect. And then we will create a tunnel joining them (just as above).
Our method for finding the correct room pair is going to be rather crude and will take into account only rooms' corners. (so it might happen that in reality there is a closer pair based on edge to edge or edge to corner distance).
I know it's a lot of nested iterators, but it gets the job done :)
// in board/dungeon/area.rs
impl Area {
// cut
fn find_closest_room_pair<'a>(&'a self, other: &'a Area) -> (&'a Room, &'a Room) {
// find closest room pair between two areas
// based on corner distances only
let mut pairs = Vec::new();
for ra in self.rooms.iter() {
for rb in other.rooms.iter() {
// find min corner dist
let d= ra.corners().iter()
.map(|ca| rb.corners().iter()
.map(|cb| ca.manhattan(*cb))
.collect::<Vec<_>>()
)
.flatten()
.min()
.unwrap();
pairs.push((d, ra, rb));
}
}
// sort by corner dist
pairs.sort_by(|a, b| a.0.cmp(&b.0));
(pairs[0].1, pairs[0].2)
}
pub fn join_area(&self, other: &Area) -> Vec<Vector2Int> {
let rooms = self.find_closest_room_pair(other);
self.join_rooms(rooms.0, rooms.1)
}
// cut
Having that, we can create simple method in the Dungeon
struct that will iterate through the area grid and connect neighbouring locations:
// in board/dungeon/mod.rs
impl Dungeon {
// cut
pub fn generate(&mut self) {
for area in self.areas.iter_mut() {
area.generate_rooms();
}
self.position_areas();
self.connect_areas();
}
// cut
fn connect_areas(&mut self) {
// connect areas based on their grid location
let mut pairs = Vec::new();
for (y, row) in self.grid.iter().enumerate() {
for (x, idx) in row.iter().enumerate() {
if x != 0 {
// join to area at x - 1
pairs.push((idx, row[x-1]));
};
if y != 0 {
// join to area at y - 1
pairs.push((idx, self.grid[y-1][x]));
};
}
}
for pair in pairs {
let path = self.areas[*pair.0].join_area(&self.areas[pair.1]);
self.areas[*pair.0].paths.push(path);
}
}
}
We simply just connect non-zero indexed areas to their left or bottom neighbours - that's it. Now if you run the game you should finally see the entire map connected :)
Next time we are going to randomize the room generation to finally have some nicer looking maps.