Maciej Główka
Blog Games Contact

← Return to Blog Index

room_003.png
June 9, 2023

Bevy roguelike tutorial / devlog part 10 - room placement

rust bevy gamedev

Last time we have left of our dungeon generation with some hardcoded room positions. This time we will improve on that by adding some proper randomization.

The complete code for this part can be found here: https://github.com/maciekglowka/hike_deck/tree/part_10

RoomGenerator

We are going to take a bit of a similar approach, as with the tunnel creation. We will make a common trait for the room generation process, so we can easily swap different algorithms. (like different tunneler types previously). That should allow us in the future to be a bit more flexible with our board designs and include special areas like temples or arenas next to the typical chambers.

So, the trait:


// in board/dungeon/room.rs
pub trait RoomGenerator {
    fn generate(&self) -> GeneratorResult;
}

pub struct GeneratorResult {
    pub rooms: Vec<Room>,
    // connections are defined by room indices
    pub connections: Vec<(usize, usize)>
}

The trait defines only a single method - one creating the room layout. As you can see in the result, apart from the rooms, we also include the desired connections between them. This way we will ensure that our chambers are logically joined together - depending on the algorithm's rules.

Now let's move to the first (and for now only) implementation. We are going to take a very simple bubbling approach here:

  1. Create a random room at (0, 0) and push it into a list
  2. Pick a room from the list as a reference (at first only one will be available)
  3. Find a random new room around the reference one
  4. Make sure the new room is valid (eg. does not overlap with any other)
  5. Connect the two rooms
  6. From time to time add a second random tunnel
  7. Go back to 2. until we hit the target room count

In order to control our randomization a little bit, we will provide the room generator with some initial parameters - like the desired room count, size ranges etc.

Here is the initial code (I hope the comments make it clear enough):


pub struct BubbleGenerator {
    // bounds for a random room count
    pub room_count: (u32, u32),
    // min max room size
    pub room_size: (u32, u32),
    // min distance between rooms
    pub room_padding: Option<u32>,
    pub extra_connection_chance: f64
}
impl BubbleGenerator {
    fn random_dim(&self) -> (i32, i32) {
        let mut rng = thread_rng();
        (
            rng.gen_range(self.room_size.0..=self.room_size.1) as i32,
            rng.gen_range(self.room_size.0..=self.room_size.1) as i32
        )
    }
}
impl RoomGenerator for BubbleGenerator {
    fn generate(&self) -> GeneratorResult {
        let mut rng = thread_rng();
        let mut connections = Vec::new();

        let (w, h) = self.random_dim();
        let mut rooms = vec![
            Room::new(
                Vector2Int::new(0, 0),
                Vector2Int::new(w, h)
            )
        ];
        // helper value for random point bounds
        let max_dist = self.room_size.1 as i32;

        let count = rng.gen_range(self.room_count.0..=self.room_count.1);
        for _ in 0..=count {
            loop {
                // take a random existing room as a base
                let prev_idx = rng.gen_range(0..rooms.len());
                // pick a random point around prev's centre
                let centre = rooms[prev_idx].centre();
                let a = Vector2Int::new(
                    rng.gen_range(centre.x - max_dist..=centre.x + max_dist),
                    rng.gen_range(centre.y - max_dist..=centre.y + max_dist)
                );
              
                // get random room size
                let (w, h) = self.random_dim();
                // get a second corner in a random direction
                let b = Vector2Int::new(
                    a.x + *[-w, w].choose(&mut rng).unwrap(),
                    a.y + *[-h, h].choose(&mut rng).unwrap()
                );
                
                let r = Room::new(a, b);
                // check for overlaps with the other rooms
                if rooms.iter().any(|other| r.intersects(other, self.room_padding)) {
                    continue;
                }
                connections.push((prev_idx, rooms.len()));

                // try creating a second connection
                if rng.gen_bool(self.extra_connection_chance) {
                    connections.push((rng.gen_range(0..rooms.len()), rooms.len()));
                }
                rooms.push(Room::new(a, b));
                // if the room is valid, we can break the randomize loop
                // and move to the next one
                break;
            }
        }
        GeneratorResult { rooms, connections }
    }
}

In order for the above to work, we also have to add two more helper methods to the Room struct:


impl Room {
    // cut
    pub fn centre(&self) -> Vector2Int {
        Vector2Int::new((self.b.x+self.a.x) / 2, (self.b.y+self.a.y) / 2)
    }
    pub fn intersects(&self, other: &Room, border: Option<u32>) -> bool {
        let b = match border {
            Some(a) => a as i32,
            None => 0
        };
        !(
            other.a.x > self.b.x + b ||
            other.b.x < self.a.x - b ||
            other.a.y > self.b.y + b ||
            other.b.y < self.a.y - b
        )
    }
}

The border parameter in the intersects method allows us to add some extra spacing around the rooms - if a positive value is provided. But you may also omit it for more organic layouts.

Now let's change the Area struct so it accepts the RoomGenerator object (as we did with the tunneler):


pub struct Area {
    pub rooms: Vec<Room>,
    pub paths: Vec<Vec<Vector2Int>>,
    pub tunneler: Box<dyn Tunneler>,
    pub room_generator: Box<dyn RoomGenerator>
}
impl Area {
    pub fn new(tunneler: Box<dyn Tunneler>, room_generator: Box<dyn RoomGenerator>) -> Self {
        Area { rooms: Vec::new(), paths: Vec::new(), tunneler, room_generator }
    }
    pub fn generate_rooms(&mut self) {
        let result = self.room_generator.generate();
        self.rooms = result.rooms;
        self.paths = result.connections.iter()
            .map(|a| self.join_rooms(&self.rooms[a.0], &self.rooms[a.1]))
            .collect();
    }
    // cut
}

One last thing left to do - include the generator object when spawning the board. You can fiddle with it's parameters and see how it affects the dungeon layout. You can even go further and use different parameters for each of the areas.


pub fn spawn_map(
    mut commands: Commands,
    mut current: ResMut<CurrentBoard>
) {
    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>
        };
        let gen = Box::new(room::BubbleGenerator {
            room_count: (3, 5),
            room_size: (4, 8),
            room_padding: Some(2),
            extra_connection_chance: 0.25
        }) as Box<dyn room::RoomGenerator>;
        dungeon.add_area(Area::new(tun, gen))
    }
    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);
    }
}

Anyways, after running the game now you should already be able to see something that might resemble a proper game map. Probably our characters will be outside of the board now, but we will handle that in the next part (or the one after that ;)

room_003.png
← Bevy roguelike tutorial / devlog part 9 - dungeon generation Minimal-UI-driven dungeon crawler→