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.
We have all the basics of a dungeon crawler now, but only having a single level is a big limitation! This chapter will introduce depth, with a new dungeon being spawned on each level down. We'll track the player's depth, and encourage ever-deeper exploration. What could possibly go wrong for the player?
i32 is a primitive type, and automatically handled by Serde - the serialization library. So adding it here automatically adds it to our game save/load mechanism. Our map creation code also needs to indicate that we are on level 1 of the map. We want to be able to use the map generator for additional levels, so we add in a parameter also. The updated function looks like this:
We call this from the setup code in main.rs, so we need to amend the call to the dungeon builder also:
#![allow(unused)]fnmain() {
let map : Map = Map::new_map_rooms_and_corridors(1);
}
That's it! Our maps now know about depth. You'll want to delete any savegame.json files you have lying around, since we've changed the format - loading will fail.
In map.rs, we have an enumeration - TileType - that lists the available tile types. We want to add a new one: down stairs. Modify the enumeration like this:
Lastly, we should place the down stairs. We place the up stairs in the center of the first room the map generates - so we'll place the stairs in the center of the last room! Going back to new_map_rooms_and_corridors in map.rs, we modify it like this:
#![allow(unused)]fnmain() {
pubfnnew_map_rooms_and_corridors(new_depth : i32) -> Map {
letmut map = Map{
tiles : vec![TileType::Wall; MAPCOUNT],
rooms : Vec::new(),
width : MAPWIDTH asi32,
height: MAPHEIGHT asi32,
revealed_tiles : vec![false; MAPCOUNT],
visible_tiles : vec![false; MAPCOUNT],
blocked : vec![false; MAPCOUNT],
tile_content : vec![Vec::new(); MAPCOUNT],
depth: new_depth
};
const MAX_ROOMS : i32 = 30;
const MIN_SIZE : i32 = 6;
const MAX_SIZE : i32 = 10;
letmut rng = RandomNumberGenerator::new();
for i in0..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);
letmut 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,2) == 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
}
}
If you cargo run the project now, and run around a bit - you can find a set of down stairs! They don't do anything yet, but they are on the map.
In player.rs, we have a big match statement that handles user input. Lets bind going to the next level to the period key (on US keyboards, that's > without the shift). Add this to the match:
Of course, now we need to implement try_next_level:
#![allow(unused)]fnmain() {
pubfntry_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 {
letmut gamelog = ecs.fetch_mut::<GameLog>();
gamelog.entries.push("There is no way down from here.".to_string());
false
}
}
}
The eagle-eyed programmer will notice that we returned a new RunState - NextLevel. Since that doesn't exist yet, we'll open main.rs and implement it:
We'll add a new impl section for State, so we can attach methods to it. We're first going to create a helper method:
#![allow(unused)]fnmain() {
impl State {
fnentities_to_remove_on_level_change(&mutself) -> 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>();
letmut to_delete : Vec<Entity> = Vec::new();
for entity in entities.join() {
letmut should_delete = true;
// Don't delete the playerlet p = player.get(entity);
ifletSome(_p) = p {
should_delete = false;
}
// Don't delete the player's equipmentlet bp = backpack.get(entity);
ifletSome(bp) = bp {
if bp.owner == *player_entity {
should_delete = false;
}
}
if should_delete {
to_delete.push(entity);
}
}
to_delete
}
}
}
When we go to the next level, we want to delete all the entities - except for the player and whatever equipment the player has. This helper function queries the ECS to obtain a list of entities for deletion. It's a bit long-winded, but relatively straightforward: we make a vector, and then iterate all entities. If the entity is the player, we mark it as should_delete=false. If it is in a backpack (having the InBackpack component), we check to see if the owner is the player - and if it is, we don't delete it.
Armed with that, we go to create the goto_next_level function, also inside the State implementation:
#![allow(unused)]fnmain() {
fngoto_next_level(&mutself) {
// Delete entities that aren't the player or his/her equipmentlet 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 playerlet worldmap;
{
letmut worldmap_resource = self.ecs.write_resource::<Map>();
let current_depth = worldmap_resource.depth;
*worldmap_resource = Map::new_map_rooms_and_corridors(current_depth + 1);
worldmap = worldmap_resource.clone();
}
// Spawn bad guysfor room in worldmap.rooms.iter().skip(1) {
spawner::spawn_room(&mutself.ecs, room);
}
// Place the player and update resourceslet (player_x, player_y) = worldmap.rooms[0].center();
letmut player_position = self.ecs.write_resource::<Point>();
*player_position = Point::new(player_x, player_y);
letmut position_components = self.ecs.write_storage::<Position>();
let player_entity = self.ecs.fetch::<Entity>();
let player_pos_comp = position_components.get_mut(*player_entity);
ifletSome(player_pos_comp) = player_pos_comp {
player_pos_comp.x = player_x;
player_pos_comp.y = player_y;
}
// Mark the player's visibility as dirtyletmut viewshed_components = self.ecs.write_storage::<Viewshed>();
let vs = viewshed_components.get_mut(*player_entity);
ifletSome(vs) = vs {
vs.dirty = true;
}
// Notify the player and give them some healthletmut gamelog = self.ecs.fetch_mut::<gamelog::GameLog>();
gamelog.entries.push("You descend to the next level, and take a moment to heal.".to_string());
letmut player_health_store = self.ecs.write_storage::<CombatStats>();
let player_health = player_health_store.get_mut(*player_entity);
ifletSome(player_health) = player_health {
player_health.hp = i32::max(player_health.hp, player_health.max_hp / 2);
}
}
}
This is a long function, but does everything we need. Lets break it down step-by-step:
We use the helper function we just wrote to obtain a list of entities to delete, and ask the ECS to dispose of them.
We create a worldmap variable, and enter a new scope. Otherwise, we get issues with immutable vs. mutable borrowing of the ECS.
In this scope, we obtain a writable reference to the resource for the current Map. We get the current level, and replace the map with a new one - with current_depth + 1 as the new depth. We then store a clone of this in the outer variable and exit the scope (avoiding any borrowing/lifetime issues).
Now we use the same code we used in the initial setup to spawn bad guys and items in each room.
Now we obtain the location of the first room, and update our resources for the player to set his/her location to the center of it. We also grab the player's Position component and update it.
We obtain the player's Viewshed component, since it will be out of date now that the entire map has changed around him/her! We mark it as dirty - and will let the various systems take care of the rest.
We give the player a log entry that they have descended to the next level.
We obtain the player's health component, and if their health is less than 50% - boost it to half.
If you cargo run the project now, you can run around and descend levels. Your depth indicator goes up - telling you that you are doing something right!
This chapter was a bit easier than the last couple! You can now descend through an effectively infinite (it's really bounded by the size of a 32-bit integer, but good luck getting through that many levels) dungeon. We've seen how the ECS can help, and how our serialization work readily expands to include new features like this one as we add to the project.
The source code for this chapter may be found here