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.
Doors and corners, that's where they get you. If we're ever going to make Miller's (from The Expanse - probably my favorite sci-fi novel series of the moment) warning come true - it would be a good idea to have doors in the game. Doors are a staple of dungeon-bashing! We've waited this long to implement them so as to ensure that we have good places to put them.
We'll start with simple, cosmetic doors that don't do anything at all. This will let us work on placing them appropriately, and then we can implement some door-related functionality. It's been a while since we added an entity type; fortunately, we have everything we need for cosmetic doors in the existing components. Open up spawner.rs, and refamiliarize yourself with it! Then we'll add a door spawner function:
So our cosmetic-only door is pretty simple: it has a glyph (+ is traditional in many roguelikes), is brown, and it has a Name and a Position. That's really all we need to make them appear on the map! We'll also modify spawn_entity to know what to do when given a Door to spawn:
This is an empty skeleton of a meta-builder. Let's deal with the easiest case first: when we have corridor data, that provides something of a blueprint as to where doors might fit. We'll start with a new function, door_possible:
There really are only two places in which a door makes sense: with east-west open and north-south blocked, and vice versa. We don't want doors to appear in open areas. So this function checks for those conditions, and returns true if a door is possible - and false otherwise. Now we expand the doors function to scan corridors and put doors at their beginning:
#![allow(unused)]fnmain() {
fndoors(&mutself, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
ifletSome(halls_original) = &build_data.corridors {
let halls = halls_original.clone(); // To avoid nested borrowingfor hall in halls.iter() {
if hall.len() > 2 { // We aren't interested in tiny corridorsifself.door_possible(build_data, hall[0]) {
build_data.spawn_list.push((hall[0], "Door".to_string()));
}
}
}
}
}
}
We start by checking that there is corridor information to use. If there is, we take a copy (to make the borrow checker happy - otherwise we're borrowing twice into halls) and iterate it. Each entry is a hallway - a vector of tiles that make up that hall. We're only interested in halls with more than 2 entries - to avoid really short corridors with doors attached. So, if its long enough - we check to see if a door makes sense at index 0 of the hall; if it does, we add it to the spawn list.
We'll quickly modify random_builder again to create a case in which there are probably doors to spawn:
It's certainly possible to scan other maps tile-by-tile looking to see if there is a possibility of a door appearing. Lets do that:
#![allow(unused)]fnmain() {
ifletSome(halls_original) = &build_data.corridors {
let halls = halls_original.clone(); // To avoid nested borrowingfor hall in halls.iter() {
if hall.len() > 2 { // We aren't interested in tiny corridorsifself.door_possible(build_data, hall[0]) {
build_data.spawn_list.push((hall[0], "Door".to_string()));
}
}
}
} else {
// There are no corridors - scan for possible placeslet tiles = build_data.map.tiles.clone();
for (i, tile) in tiles.iter().enumerate() {
if *tile == TileType::Floor && self.door_possible(build_data, i) {
build_data.spawn_list.push((i, "Door".to_string()));
}
}
}
}
}
Modify your random_builder to use a map without hallways:
Notice that we added it before we add vaults; that's deliberate - the vault gets the chance to spawn and remove any doors that would interfere with it.
Doors have a few properties: when closed, they block movement and visibility. They can be opened (optionally requiring unlocking, but we're not going there yet), at which point you can see through them just fine.
Let's start by "blocking out" (suggesting!) some new components. In spawner.rs:
BlocksVisibility will do what it says - prevent you (and monsters) from seeing through it. It's nice to have this as a component rather than a special-case, because now you can make anything block visibility. A really big treasure chest, a giant or even a moving wall - it makes sense to be able to prevent seeing through them.
Door - which denotes that it is a door, and will need its own handling.
Open up components.rs and we'll make these new components:
Moving against a closed door should open it, and then you can pass freely through (we could add an open and close command - maybe we will later - but for now lets keep it simple). Open up player.rs, and we'll add the functionality to try_move_player:
On the non-corridor maps, there is a slight problem when play-testing the door placement: there are doors everywhere. Lets reduce the frequency of door placement. We'll just add a little randomness:
#![allow(unused)]fnmain() {
fndoors(&mutself, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
ifletSome(halls_original) = &build_data.corridors {
let halls = halls_original.clone(); // To avoid nested borrowingfor hall in halls.iter() {
if hall.len() > 2 { // We aren't interested in tiny corridorsifself.door_possible(build_data, hall[0]) {
build_data.spawn_list.push((hall[0], "Door".to_string()));
}
}
}
} else {
// There are no corridors - scan for possible placeslet tiles = build_data.map.tiles.clone();
for (i, tile) in tiles.iter().enumerate() {
if *tile == TileType::Floor && self.door_possible(build_data, i) && rng.roll_dice(1,3)==1 {
build_data.spawn_list.push((i, "Door".to_string()));
}
}
}
}
}
This gives a 1 in 3 chance of any possible door placement yielding a door. From playing the game, this feels about right. It may not work for you - so you can change it! You may even want to make it a parameter.
Sometimes, a door spawns on top of another entity. It's rare, but it can happen. Lets prevent that issue from occurring. We can fix this with a quick scan of the spawn list in door_possible:
#![allow(unused)]fnmain() {
fndoor_possible(&self, build_data : &mut BuilderMap, idx : usize) -> bool {
letmut blocked = false;
for spawn in build_data.spawn_list.iter() {
if spawn.0 == idx { blocked = true; }
}
if blocked { returnfalse; }
...
}
If speed becomes a concern, this would be easy to speed up (make a quick HashSet of occupied tiles, and query that instead of the whole list) - but we haven't really had any performance issues, and map building runs outside of the main loop (so it's once per level, not every frame) - so chances are that you don't need it.
In our random_builder, we've made a mistake! Wave Function Collapse changes the nature of maps, and should adjust spawn, entry and exit points. Here's the correct code:
#![allow(unused)]fnmain() {
if rng.roll_dice(1, 3)==1 {
builder.with(WaveformCollapseBuilder::new());
// Now set the start to a random starting arealet (start_x, start_y) = random_start_position(rng);
builder.with(AreaStartingPosition::new(start_x, start_y));
// Setup an exit and spawn mobs
builder.with(VoronoiSpawning::new());
builder.with(DistantExit::new());
}
}
That's it for doors! There's definitely room for improvement in the future - but the feature is working. You can approach a door, and it blocks both movement and line-of-sight (so the occupants of the room won't bother you). Open it, and you can see through - and the occupants can see you back. Now it's open, you can travel through it. That's pretty close to the definition of a door!
...
The source code for this chapter may be found here