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.
Now that we have a nice, clean layering system we'll take the opportunity to play with it a bit. This chapter is a collection of fun things you can do with layers, and will introduce a few new layer types. It's meant to whet your appetite to write more: the sky really is the limit!
When we wrote the Cellular Automata system, we aimed for a generic cavern builder. The algorithm is capable of quite a bit more than that - each iteration is basically a "meta builder" running on the previous iteration. A simple tweak allows it to also be a meta-builder that only runs a single iteration.
We'll start by moving the code for a single iteration into its own function:
See how we're calling a single iteration, instead of replacing the whole map? This shows how we can apply the cellular automata rules to the map - and change the resultant character quite a bit.
Now lets modify map_builders/mod.rs's random_builder to force it to use this as an example:
There's also plenty of scope to write new map filters. We'll explore a few of the more interesting ones in this section. Pretty much anything you might use as an image filter in a program like Photoshop (or the GIMP!) could be adapted for this purpose. How useful a given filter is remains an open/interesting question!
Nethack-style boxy rooms make for very early-D&D type play, but people often remark that they aren't all that visually pleasing or interesting. One way to keep the basic room style, but get a more organic look, is to run drunkard's walk inside each room. I like to call this "exploding the room" - because it looks a bit like you set off dynamite in each room. In map_builders/, make a new file room_exploder.rs:
#![allow(unused)]fnmain() {
use super::{MetaMapBuilder, BuilderMap, TileType, paint, Symmetry, Rect};
use rltk::RandomNumberGenerator;
pubstructRoomExploder {}
impl MetaMapBuilder for RoomExploder {
fnbuild_map(&mutself, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) {
self.build(rng, build_data);
}
}
impl RoomExploder {
#[allow(dead_code)]pubfnnew() -> Box<RoomExploder> {
Box::new(RoomExploder{})
}
fnbuild(&mutself, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
let rooms : Vec<Rect>;
ifletSome(rooms_builder) = &build_data.rooms {
rooms = rooms_builder.clone();
} else {
panic!("Room Explosions require a builder with room structures");
}
for room in rooms.iter() {
let start = room.center();
let n_diggers = rng.roll_dice(1, 20)-5;
if n_diggers > 0 {
for _i in0..n_diggers {
letmut drunk_x = start.0;
letmut drunk_y = start.1;
letmut drunk_life = 20;
letmut did_something = false;
while drunk_life > 0 {
let drunk_idx = build_data.map.xy_idx(drunk_x, drunk_y);
if build_data.map.tiles[drunk_idx] == TileType::Wall {
did_something = true;
}
paint(&mut build_data.map, Symmetry::None, 1, drunk_x, drunk_y);
build_data.map.tiles[drunk_idx] = TileType::DownStairs;
let stagger_direction = rng.roll_dice(1, 4);
match stagger_direction {
1 => { if drunk_x > 2 { drunk_x -= 1; } }
2 => { if drunk_x < build_data.map.width-2 { drunk_x += 1; } }
3 => { if drunk_y > 2 { drunk_y -=1; } }
_ => { if drunk_y < build_data.map.height-2 { drunk_y += 1; } }
}
drunk_life -= 1;
}
if did_something {
build_data.take_snapshot();
}
for t in build_data.map.tiles.iter_mut() {
if *t == TileType::DownStairs {
*t = TileType::Floor;
}
}
}
}
}
}
}
}
There's nothing too surprising in this code: it takes the rooms list from the parent build data, and then iterates each room. A random number (which can be zero) of drunkards is then run from the center of each room, with a short lifespan, carving out the edges of each room. You can test this with the following random_builder code:
Another quick and easy way to make a boxy map look less rectangular is to smooth the corners a bit. Add room_corner_rounding.rs to map_builders/:
#![allow(unused)]fnmain() {
use super::{MetaMapBuilder, BuilderMap, TileType, Rect};
use rltk::RandomNumberGenerator;
pubstructRoomCornerRounder {}
impl MetaMapBuilder for RoomCornerRounder {
fnbuild_map(&mutself, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) {
self.build(rng, build_data);
}
}
impl RoomCornerRounder {
#[allow(dead_code)]pubfnnew() -> Box<RoomCornerRounder> {
Box::new(RoomCornerRounder{})
}
fnfill_if_corner(&mutself, x: i32, y: i32, build_data : &mut BuilderMap) {
let w = build_data.map.width;
let h = build_data.map.height;
let idx = build_data.map.xy_idx(x, y);
letmut neighbor_walls = 0;
if x > 0 && build_data.map.tiles[idx-1] == TileType::Wall { neighbor_walls += 1; }
if y > 0 && build_data.map.tiles[idx-w asusize] == TileType::Wall { neighbor_walls += 1; }
if x < w-2 && build_data.map.tiles[idx+1] == TileType::Wall { neighbor_walls += 1; }
if y < h-2 && build_data.map.tiles[idx+w asusize] == TileType::Wall { neighbor_walls += 1; }
if neighbor_walls == 2 {
build_data.map.tiles[idx] = TileType::Wall;
}
}
fnbuild(&mutself, _rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
let rooms : Vec<Rect>;
ifletSome(rooms_builder) = &build_data.rooms {
rooms = rooms_builder.clone();
} else {
panic!("Room Rounding require a builder with room structures");
}
for room in rooms.iter() {
self.fill_if_corner(room.x1+1, room.y1+1, build_data);
self.fill_if_corner(room.x2, room.y1+1, build_data);
self.fill_if_corner(room.x1+1, room.y2, build_data);
self.fill_if_corner(room.x2, room.y2, build_data);
build_data.take_snapshot();
}
}
}
}
The boilerplate (repeated code) should look familiar by now, so we'll focus on the algorithm in build:
We obtain a list of rooms, and panic! if there aren't any.
For each of the 4 corners of the room, we call a new function fill_if_corner.
fill_if_corner counts each of the neighboring tiles to see if it is a wall. If there are exactly 2 walls, then this tile is eligible to become a corner - so we fill in a wall.
You can try it out with the following random_builder code:
There's a fair amount of shared code between BSP room placement and "simple map" room placement - but with different corridor decision-making. What if we were to de-couple the stages - so the room algorithms decide where the rooms go, another algorithm draws them (possibly changing how they are drawn), and a third algorithm places corridors? Our improved framework supports this with just a bit of algorithm tweaking.
Here's simple_map.rs with the corridor code removed:
#![allow(unused)]fnmain() {
use super::{InitialMapBuilder, BuilderMap, Rect, apply_room_to_map,
apply_horizontal_tunnel, apply_vertical_tunnel };
use rltk::RandomNumberGenerator;
pubstructSimpleMapBuilder {}
impl InitialMapBuilder for SimpleMapBuilder {
#[allow(dead_code)]fnbuild_map(&mutself, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) {
self.build_rooms(rng, build_data);
}
}
impl SimpleMapBuilder {
#[allow(dead_code)]pubfnnew() -> Box<SimpleMapBuilder> {
Box::new(SimpleMapBuilder{})
}
fnbuild_rooms(&mutself, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
const MAX_ROOMS : i32 = 30;
const MIN_SIZE : i32 = 6;
const MAX_SIZE : i32 = 10;
letmut rooms : Vec<Rect> = Vec::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, build_data.map.width - w - 1) - 1;
let y = rng.roll_dice(1, build_data.map.height - h - 1) - 1;
let new_room = Rect::new(x, y, w, h);
letmut ok = true;
for other_room in rooms.iter() {
if new_room.intersect(other_room) { ok = false }
}
if ok {
apply_room_to_map(&mut build_data.map, &new_room);
build_data.take_snapshot();
rooms.push(new_room);
build_data.take_snapshot();
}
}
build_data.rooms = Some(rooms);
}
}
}
Other than renaming rooms_and_corridors to just build_rooms, the only change is removing the dice roll to place corridors.
Lets make a new file, map_builders/rooms_corridors_dogleg.rs. This is where we place the corridors. For now, we'll use the same algorithm we just removed from SimpleMapBuilder:
#![allow(unused)]fnmain() {
use super::{MetaMapBuilder, BuilderMap, Rect, apply_horizontal_tunnel, apply_vertical_tunnel };
use rltk::RandomNumberGenerator;
pubstructDoglegCorridors {}
impl MetaMapBuilder for DoglegCorridors {
#[allow(dead_code)]fnbuild_map(&mutself, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) {
self.corridors(rng, build_data);
}
}
impl DoglegCorridors {
#[allow(dead_code)]pubfnnew() -> Box<DoglegCorridors> {
Box::new(DoglegCorridors{})
}
fncorridors(&mutself, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
let rooms : Vec<Rect>;
ifletSome(rooms_builder) = &build_data.rooms {
rooms = rooms_builder.clone();
} else {
panic!("Dogleg Corridors require a builder with room structures");
}
for (i,room) in rooms.iter().enumerate() {
if i > 0 {
let (new_x, new_y) = room.center();
let (prev_x, prev_y) = rooms[i asusize -1].center();
if rng.range(0,2) == 1 {
apply_horizontal_tunnel(&mut build_data.map, prev_x, new_x, prev_y);
apply_vertical_tunnel(&mut build_data.map, prev_y, new_y, new_x);
} else {
apply_vertical_tunnel(&mut build_data.map, prev_y, new_y, prev_x);
apply_horizontal_tunnel(&mut build_data.map, prev_x, new_x, new_y);
}
build_data.take_snapshot();
}
}
}
}
}
Again - this is the code we just removed, but placed into a new builder by itself. So there's really nothing new. We can adjust random_builder to test this code:
It's easy to do the same to our BSPDungeonBuilder. In bsp_dungeon.rs, we also trim out the corridor code. We'll just include the build function for brevity:
#![allow(unused)]fnmain() {
fnbuild(&mutself, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
letmut rooms : Vec<Rect> = Vec::new();
self.rects.clear();
self.rects.push( Rect::new(2, 2, build_data.map.width-5, build_data.map.height-5) ); // Start with a single map-sized rectanglelet first_room = self.rects[0];
self.add_subrects(first_room); // Divide the first room// Up to 240 times, we get a random rectangle and divide it. If its possible to squeeze a// room in there, we place it and add it to the rooms list.letmut n_rooms = 0;
while n_rooms < 240 {
let rect = self.get_random_rect(rng);
let candidate = self.get_random_sub_rect(rect, rng);
ifself.is_possible(candidate, &build_data.map) {
apply_room_to_map(&mut build_data.map, &candidate);
rooms.push(candidate);
self.add_subrects(rect);
build_data.take_snapshot();
}
n_rooms += 1;
}
build_data.rooms = Some(rooms);
}
}
We'll also move our BSP corridor code into a new builder, without the room sorting (we'll be touching on sorting in the next heading!). Create the new file map_builders/rooms_corridors_bsp.rs:
#![allow(unused)]fnmain() {
use super::{MetaMapBuilder, BuilderMap, Rect, draw_corridor };
use rltk::RandomNumberGenerator;
pubstructBspCorridors {}
impl MetaMapBuilder for BspCorridors {
#[allow(dead_code)]fnbuild_map(&mutself, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) {
self.corridors(rng, build_data);
}
}
impl BspCorridors {
#[allow(dead_code)]pubfnnew() -> Box<BspCorridors> {
Box::new(BspCorridors{})
}
fncorridors(&mutself, rng : &mut RandomNumberGenerator, build_data : &mut BuilderMap) {
let rooms : Vec<Rect>;
ifletSome(rooms_builder) = &build_data.rooms {
rooms = rooms_builder.clone();
} else {
panic!("BSP Corridors require a builder with room structures");
}
for i in0..rooms.len()-1 {
let room = rooms[i];
let next_room = rooms[i+1];
let start_x = room.x1 + (rng.roll_dice(1, i32::abs(room.x1 - room.x2))-1);
let start_y = room.y1 + (rng.roll_dice(1, i32::abs(room.y1 - room.y2))-1);
let end_x = next_room.x1 + (rng.roll_dice(1, i32::abs(next_room.x1 - next_room.x2))-1);
let end_y = next_room.y1 + (rng.roll_dice(1, i32::abs(next_room.y1 - next_room.y2))-1);
draw_corridor(&mut build_data.map, start_x, start_y, end_x, end_y);
build_data.take_snapshot();
}
}
}
}
Again, this is the corridor code from BspDungeonBuilder - just fitted into its own builder stage. You can prove that it works by modifying random_builder once again:
If you cargo run it, you'll see something like this:
.
That looks like it works - but if you pay close attention, you'll see why we sorted the rooms in the original algorithm: there's lots of overlap between rooms/corridors, and corridors don't trend towards the shortest path. This was deliberate - we need to make a RoomSorter builder, to give us some more map-building options. Lets create map_builders/room_sorter.rs:
Breaking the sorter into its own step is only really useful if we're going to come up with some different ways to sort the rooms! We're currently sorting by the left-most entry - giving a map that gradually works its way East, but jumps around.
Now that we're getting towards the end of this section (not there yet!), lets take the time to really take advantage of what we've built so far. We're going to completely restructure the way we're selecting a random build pattern.
Room-based spawning isn't as embarrassingly predictable as it used to be, now. So lets make a function that exposes all of the room variety we've built so far:
#![allow(unused)]fnmain() {
fnrandom_room_builder(rng: &mut rltk::RandomNumberGenerator, builder : &mut BuilderChain) {
let build_roll = rng.roll_dice(1, 3);
match build_roll {
1 => builder.start_with(SimpleMapBuilder::new()),
2 => builder.start_with(BspDungeonBuilder::new()),
_ => builder.start_with(BspInteriorBuilder::new())
}
// BSP Interior still makes holes in the wallsif build_roll != 3 {
// Sort by one of the 5 available algorithmslet sort_roll = rng.roll_dice(1, 5);
match sort_roll {
1 => builder.with(RoomSorter::new(RoomSort::LEFTMOST)),
2 => builder.with(RoomSorter::new(RoomSort::RIGHTMOST)),
3 => builder.with(RoomSorter::new(RoomSort::TOPMOST)),
4 => builder.with(RoomSorter::new(RoomSort::BOTTOMMOST)),
_ => builder.with(RoomSorter::new(RoomSort::CENTRAL)),
}
let corridor_roll = rng.roll_dice(1, 2);
match corridor_roll {
1 => builder.with(DoglegCorridors::new()),
_ => builder.with(BspCorridors::new())
}
let modifier_roll = rng.roll_dice(1, 6);
match modifier_roll {
1 => builder.with(RoomExploder::new()),
2 => builder.with(RoomCornerRounder::new()),
_ => {}
}
}
let start_roll = rng.roll_dice(1, 2);
match start_roll {
1 => builder.with(RoomBasedStartingPosition::new()),
_ => {
let (start_x, start_y) = random_start_position(rng);
builder.with(AreaStartingPosition::new(start_x, start_y));
}
}
let exit_roll = rng.roll_dice(1, 2);
match exit_roll {
1 => builder.with(RoomBasedStairs::new()),
_ => builder.with(DistantExit::new())
}
let spawn_roll = rng.roll_dice(1, 2);
match spawn_roll {
1 => builder.with(RoomBasedSpawner::new()),
_ => builder.with(VoronoiSpawning::new())
}
}
}
That's a big function, so we'll step through it. It's quite simple, just really spread out and full of branches:
We roll 1d3, and pick from BSP Interior, Simple and BSP Dungeon map builders.
If we didn't pick BSP Interior (which does a lot of stuff itself), we:
Randomly pick a room sorting algorithm.
Randomly pick one of the two corridor algorithms we now have.
Randomly pick (or ignore) a room exploder or corner-rounder.
We randomly choose between a Room-based starting position, and an area-based starting position. For the latter, call random_start_position to pick between 3 X-axis and 3 Y-axis starting positions to favor.
We randomly choose between a Room-based stairs placement and a "most distant from the start" exit.
We randomly choose between Voronoi-area spawning and room-based spawning.
So that function is all about rolling dice, and making a map! It's a lot of combinations, even ignoring the thousands of possible layouts that can come from each starting builder. There are:
So this function is offering 488 possible builder combinations!.
Now we'll create a function for the non-room spawners:
#![allow(unused)]fnmain() {
fnrandom_shape_builder(rng: &mut rltk::RandomNumberGenerator, builder : &mut BuilderChain) {
let builder_roll = rng.roll_dice(1, 16);
match builder_roll {
1 => builder.start_with(CellularAutomataBuilder::new()),
2 => builder.start_with(DrunkardsWalkBuilder::open_area()),
3 => builder.start_with(DrunkardsWalkBuilder::open_halls()),
4 => builder.start_with(DrunkardsWalkBuilder::winding_passages()),
5 => builder.start_with(DrunkardsWalkBuilder::fat_passages()),
6 => builder.start_with(DrunkardsWalkBuilder::fearful_symmetry()),
7 => builder.start_with(MazeBuilder::new()),
8 => builder.start_with(DLABuilder::walk_inwards()),
9 => builder.start_with(DLABuilder::walk_outwards()),
10 => builder.start_with(DLABuilder::central_attractor()),
11 => builder.start_with(DLABuilder::insectoid()),
12 => builder.start_with(VoronoiCellBuilder::pythagoras()),
13 => builder.start_with(VoronoiCellBuilder::manhattan()),
_ => builder.start_with(PrefabBuilder::constant(prefab_builder::prefab_levels::WFC_POPULATED)),
}
// Set the start to the center and cull
builder.with(AreaStartingPosition::new(XStart::CENTER, YStart::CENTER));
builder.with(CullUnreachable::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());
}
}
This is similar to what we've done before, but with a twist: we now place the player centrally, cull unreachable areas, and then place the player in a random location. It's likely that the middle of a generated map is quite connected - so this gets rid of dead space, and minimizes the likelihood of starting in an "orphaned" section and culling the map down to just a few tiles.
This also provides a lot of combinations, but not quite as many.
This is relatively straightforward. We randomly pick either a room or a shape builder, as defined above. There's a 1 in 3 chance we'll then run Wave Function Collapse on it, and a 1 in 20 chance that we'll add a sectional to it. Finally, we try to spawn any vaults we might want to use.
So how does our total combinatorial explosion look? Pretty good at this point:
488 possible room builders +
84 possible shape builders =
572 builder combinations.
We might run Wave Function Collapse, giving another 2 options:
*2 = 1,144
We might add a sectional:
*2 = 2,288
So we now have 2,288 possible builder combinations, just from the last few chapters. Combine that with a random seed, and it's increasingly unlikely that a player will see the exact same combination of maps on a run twice.
...
The source code for this chapter may be found here