Making the starting town
About this tutorial
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.
What is the town for?
Back in the Design Document we decided: The game starts in town. In town, there are only minimal enemies (pickpockets, thugs). You start in the to-be-named pub (tavern), armed only with a meager purse, minimal starting equipment, a stein of beer, a dried sausage, a backpack and a hangover. Town lets you visit various vendors.
From a development point of view, this tells us a few things:
- The town has a story aspect, in that you start there and it ground the story - giving a starting point, a destiny (in this case a drunken promise to save the world). So the town implies a certain cozy starting point, implies some communication to help you understand why you are embarking on the life of an adventurer, and so on.
- The town has vendors. That won't make sense at this point, because we don't have a value/currency system - but we know that we need somewhere to put them.
- The town has a tavern/inn/pub - it's a starting location, but it's obviously important enough that it needs to do something!
- Elsewhere in the design document, we mention that you can town portal back to the settlement. This again implies a certain coziness/safety, and also implies that doing so is useful - so the services offered by the town need to retain their utility throughout the game.
- Finally, the town is the winning condition: once you've grabbed the Amulet of Yala - getting back to town lets you save the world. That implies that the town should have some sort of holy structure to which you have to return the amulet.
- The town is the first thing that new players will encounter - so it has to look alive and somewhat slick, or players will just close the window and try something else. It may also serve as a location for some tutorials.
This sort of discussion is essential to game design; you don't want to implement something just because you can (in most cases; big open world games relax that a bit). The town has a purpose, and that purpose guides its design.
So what do we have to include in the town?
So that discussion lets us determine that the town must include:
- One or more merchants. We're not implementing the sale of goods yet, but they need a place to operate.
- Some friendly/neutral NPCs for color.
- A temple.
- A tavern.
- A place that town portals arrive.
- A way out to begin your adventure.
We can also think a little bit about what makes a town:
- There's generally a communication route (land or sea), otherwise the town won't prosper.
- Frequently, there's a market (surrounding villages use towns for commerce).
- There's almost certainly either a river or a deep natural water source.
- Towns typically have authority figures, visible at least as Guards or Watch.
- Towns also generally have a shady side.
How do we want to generate our town?
We could go for a prefabricated town. This has the upside that the town can be tweaked until it's just right, and plays smoothly. It has the downside that getting out of the town becomes a purely mechanical step after the first couple of play-throughs ("runs"); look at Joppa in Caves of Qud - it became little more than a "grab the chest content, talk to these guys, and off you go" speed-bump start to an amazing game.
So - we want a procedurally generated town, but we want to keep it functional - and make it pretty. Not much to ask!
Making some new tile types
From the above, it sounds like we are going to need some new tiles. The ones that spring to mind for a town are roads, grass, water (both deep and shallow), bridge, wooden floors, and building walls. One thing we can count on: we're going to add lots of new tile types as we progress, so we better take the time to make it a seamless experience up-front!
The map.rs
could get quite complicated if we're not careful, so lets make it into its own module with a directory. We'll start by making a directory, map/
. Then we'll move map.rs
into it, and rename it mod.rs
. Now, we'll take TileType
out of mod.rs
and put it into a new file - tiletype.rs
:
#![allow(unused)] fn main() { use serde::{Serialize, Deserialize}; #[derive(PartialEq, Eq, Hash, Copy, Clone, Serialize, Deserialize)] pub enum TileType { Wall, Floor, DownStairs } }
And in mod.rs
we'll accept the module and share the public types it exposes:
#![allow(unused)] fn main() { mod tiletype; pub use tiletype::TileType; }
This hasn't gained us much yet... but now we can start supporting the various tile types. As we add functionality, you'll hopefully see why using a separate file makes it easier to find the relevant code:
#![allow(unused)] fn main() { #[derive(PartialEq, Eq, Hash, Copy, Clone, Serialize, Deserialize)] pub enum TileType { Wall, Floor, DownStairs, Road, Grass, ShallowWater, DeepWater, WoodFloor, Bridge } }
This is only part of the picture, because now we need to handle a bunch of grunt-work: can you enter tiles of that type, do they block visibility, do they have a different cost for path-finding, and so on. We've also done a lot of "spawn if its a floor" code in our map builders; maybe that wasn't such a good idea if you can have multiple floor types? Anyway, the current map.rs
provides some of what we need in order to satisfy the BaseMap
trait for RLTK.
We'll make a few functions to help satisfy this requirement, while keeping our tile functionality in one place:
#![allow(unused)] fn main() { pub fn tile_walkable(tt : TileType) -> bool { match tt { TileType::Floor | TileType::DownStairs | TileType::Road | TileType::Grass | TileType::ShallowWater | TileType::WoodFloor | TileType::Bridge => true, _ => false } } pub fn tile_opaque(tt : TileType) -> bool { match tt { TileType::Wall => true, _ => false } } }
Now we'll go back into mod.rs
, and import these - and make them public to anyone who wants them:
#![allow(unused)] fn main() { mod tiletype; pub use tiletype::{TileType, tile_walkable, tile_opaque}; }
We also need to update some of our functions to use this functionality. We determine a lot of path-finding with the blocked
system, so we need to update populate_blocked
to handle the various types using the functions we just made:
#![allow(unused)] fn main() { pub fn populate_blocked(&mut self) { for (i,tile) in self.tiles.iter_mut().enumerate() { self.blocked[i] = !tile_walkable(*tile); } } }
We also need to update our visibility determination code:
#![allow(unused)] fn main() { impl BaseMap for Map { fn is_opaque(&self, idx:i32) -> bool { let idx_u = idx as usize; if idx_u > 0 && idx_u < self.tiles.len() { tile_opaque(self.tiles[idx_u]) || self.view_blocked.contains(&idx_u) } else { true } } ... }
Lastly, lets look at get_available_exits
. This uses the blocked system to determine if an exit is possible, but so far we've hard-coded all of our costs. When there is just a floor and a wall to choose from, it is a pretty easy choice after all! Once we start offering choices, we might want to encourage certain behaviors. It would certainly look more realistic if people preferred to travel on the road than the grass, and definitely more realistic if they avoid standing in shallow water unless they need to. So we'll build a cost function (in tiletype.rs
):
#![allow(unused)] fn main() { pub fn tile_cost(tt : TileType) -> f32 { match tt { TileType::Road => 0.8, TileType::Grass => 1.1, TileType::ShallowWater => 1.2, _ => 1.0 } } }
Then we update our get_available_exits
to use it:
#![allow(unused)] fn main() { fn get_available_exits(&self, idx:i32) -> Vec<(i32, f32)> { let mut exits : Vec<(i32, f32)> = Vec::new(); let x = idx % self.width; let y = idx / self.width; let tt = self.tiles[idx as usize]; // Cardinal directions if self.is_exit_valid(x-1, y) { exits.push((idx-1, tile_cost(tt))) }; if self.is_exit_valid(x+1, y) { exits.push((idx+1, tile_cost(tt))) }; if self.is_exit_valid(x, y-1) { exits.push((idx-self.width, tile_cost(tt))) }; if self.is_exit_valid(x, y+1) { exits.push((idx+self.width, tile_cost(tt))) }; // Diagonals if self.is_exit_valid(x-1, y-1) { exits.push(((idx-self.width)-1, tile_cost(tt) * 1.45)); } if self.is_exit_valid(x+1, y-1) { exits.push(((idx-self.width)+1, tile_cost(tt) * 1.45)); } if self.is_exit_valid(x-1, y+1) { exits.push(((idx+self.width)-1, tile_cost(tt) * 1.45)); } if self.is_exit_valid(x+1, y+1) { exits.push(((idx+self.width)+1, tile_cost(tt) * 1.45)); } exits } }
We've replaced all the costs of 1.0
with a call to our tile_cost
function, and multiplied diagonals by 1.45 to encourage more natural looking movement.
Fixing our camera
We also need to be able to render these tile types, so we open up camera.rs
and add them to the match
statement in get_tile_glyph
:
#![allow(unused)] fn main() { fn get_tile_glyph(idx: usize, map : &Map) -> (rltk::FontCharType, RGB, RGB) { let glyph; let mut fg; let mut bg = RGB::from_f32(0., 0., 0.); match map.tiles[idx] { TileType::Floor => { glyph = rltk::to_cp437('.'); fg = RGB::from_f32(0.0, 0.5, 0.5); } TileType::WoodFloor => { glyph = rltk::to_cp437('.'); fg = RGB::named(rltk::CHOCOLATE); } TileType::Wall => { let x = idx as i32 % map.width; let y = idx as i32 / map.width; glyph = wall_glyph(&*map, x, y); fg = RGB::from_f32(0., 1.0, 0.); } TileType::DownStairs => { glyph = rltk::to_cp437('>'); fg = RGB::from_f32(0., 1.0, 1.0); } TileType::Bridge => { glyph = rltk::to_cp437('.'); fg = RGB::named(rltk::CHOCOLATE); } TileType::Road => { glyph = rltk::to_cp437('~'); fg = RGB::named(rltk::GRAY); } TileType::Grass => { glyph = rltk::to_cp437('"'); fg = RGB::named(rltk::GREEN); } TileType::ShallowWater => { glyph = rltk::to_cp437('≈'); fg = RGB::named(rltk::CYAN); } TileType::DeepWater => { glyph = rltk::to_cp437('≈'); fg = RGB::named(rltk::NAVY_BLUE); } } if map.bloodstains.contains(&idx) { bg = RGB::from_f32(0.75, 0., 0.); } if !map.visible_tiles[idx] { fg = fg.to_greyscale(); bg = RGB::from_f32(0., 0., 0.); // Don't show stains out of visual range } (glyph, fg, bg) } }
Starting to build our town
We want to stop making maps randomly, and instead start being a bit predictable in what we make. So when you start depth 1, you always get a town. In map_builders/mod.rs
, we'll make a new function. For now, it'll just fall back to being random:
#![allow(unused)] fn main() { pub fn level_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { random_builder(new_depth, rng, width, height) } }
Pop over to main.rs
and change the builder function call to use our new function:
#![allow(unused)] fn main() { fn generate_world_map(&mut self, new_depth : i32) { self.mapgen_index = 0; self.mapgen_timer = 0.0; self.mapgen_history.clear(); let mut rng = self.ecs.write_resource::<rltk::RandomNumberGenerator>(); let mut builder = map_builders::level_builder(new_depth, &mut rng, 80, 50); ... }
Now, we'll start fleshing out our level_builder
; we want depth 1 to generate a town map - otherwise, we'll stick with random for now. We also want it to be obvious via a match
statement how we're routing each level's procedural generation:
#![allow(unused)] fn main() { pub fn level_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { rltk::console::log(format!("Depth: {}", new_depth)); match new_depth { 1 => town_builder(new_depth, rng, width, height), _ => random_builder(new_depth, rng, width, height) } } }
At the top of the mod.rs
file, add:
#![allow(unused)] fn main() { mod town; use town::town_builder; }
And in a new file, map_builders/town.rs
we'll begin our function:
#![allow(unused)] fn main() { use super::BuilderChain; pub fn level_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { let mut chain = BuilderChain::new(new_depth, width, height); chain.start_with(TownBuilder::new()); let (start_x, start_y) = super::random_start_position(rng); chain.with(AreaStartingPosition::new(start_x, start_y)); chain.with(DistantExit::new()); chain } }
The AreaStartingPosition
and DistantExit
are temporary to get us valid start/end points. The meat is the call to TownBuilder
. We haven't written that yet, so we'll work through step-by-step until we have a town we like!
Here's an empty skeleton to start with:
#![allow(unused)] fn main() { pub struct TownBuilder {} impl InitialMapBuilder for TownBuilder { #[allow(dead_code)] fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build_rooms(rng, build_data); } } impl TownBuilder { pub fn new() -> Box<TownBuilder> { Box::new(TownBuilder{}) } pub fn build_rooms(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { } } }
Let's make a fishing town
Let's start by adding grass, water and piers to the region. We'll write the skeleton first:
#![allow(unused)] fn main() { pub fn build_rooms(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.grass_layer(build_data); self.water_and_piers(rng, build_data); // Make visible for screenshot for t in build_data.map.visible_tiles.iter_mut() { *t = true; } build_data.take_snapshot(); } }
The function grass_layer
is really simple: we replace everything with grass:
#![allow(unused)] fn main() { fn grass_layer(&mut self, build_data : &mut BuilderMap) { // We'll start with a nice layer of grass for t in build_data.map.tiles.iter_mut() { *t = TileType::Grass; } build_data.take_snapshot(); } }
Adding water is more interesting. We don't want it to be the same each time, but we want to keep the same basic structure. Here's the code:
#![allow(unused)] fn main() { fn water_and_piers(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { let mut n = (rng.roll_dice(1, 65535) as f32) / 65535f32; let mut water_width : Vec<i32> = Vec::new(); for y in 0..build_data.height { let n_water = (f32::sin(n) * 10.0) as i32 + 14 + rng.roll_dice(1, 6); water_width.push(n_water); n += 0.1; for x in 0..n_water { let idx = build_data.map.xy_idx(x, y); build_data.map.tiles[idx] = TileType::DeepWater; } for x in n_water .. n_water+3 { let idx = build_data.map.xy_idx(x, y); build_data.map.tiles[idx] = TileType::ShallowWater; } } build_data.take_snapshot(); // Add piers for _i in 0..rng.roll_dice(1, 4)+6 { let y = rng.roll_dice(1, build_data.height)-1; for x in 2 + rng.roll_dice(1, 6) .. water_width[y as usize] + 4 { let idx = build_data.map.xy_idx(x, y); build_data.map.tiles[idx] = TileType::WoodFloor; } } build_data.take_snapshot(); } }
There's quite a bit going on here, so we'll step through:
- We make
n
equal to a random floating point number between0.0
and1.0
by rolling a 65,535 sided dice (wouldn't it be nice if one of those existed?) and dividing by the maximum number. - We make a new vector called
water_width
. We'll store the number of water tiles on each row in here as we generate them. - For each
y
row down the map:- We make
n_water
. This is the number of water tiles present. We start by taking thesin
(Sine) ofn
(we randomized it to give a random gradient). Sin waves are great, they give a nice predictable curve and you can read anywhere along them to determine where the curve is. Sincesin
gives a number from -1 to 1, we multiply by 10 to give -10 to +10. We then add 14, guaranteeing between 4 and 24 tiles of water. To make it look jagged, we add a little bit of randomness also. - We
push
this into thewater_width
vector, storing it for later. - We add
0.1
ton
, progressing along the sine wave. - Then we iterate from 0 to
n_water
(asx
) and writeDeepWater
tiles to the position of each water tile. - We go from
n_water
ton_water+3
to add some shallow water at the edge.
- We make
- We take a snapshot so you can watch the map progression.
- We iterate from 0 to 1d4+6 to generate between 10 and 14 piers.
- We pick
y
at random. - We look up the water placement for that
y
value, and draw wooden floors starting at 2+1d6 towater_width[y]+4
- giving a pier that extends out into the water for some way, and ends squarely on land.
- We pick
If you cargo run
, you'll see a map like this now:
Adding town walls, gravel and a road
Now that we have some terrain, we should add some initial outline to the town. Extend the build
function with another function call:
#![allow(unused)] fn main() { let (mut available_building_tiles, wall_gap_y) = self.town_walls(rng, build_data); }
The function looks like this:
#![allow(unused)] fn main() { fn town_walls(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) -> (HashSet<usize>, i32) { let mut available_building_tiles : HashSet<usize> = HashSet::new(); let wall_gap_y = rng.roll_dice(1, build_data.height - 9) + 5; for y in 1 .. build_data.height-2 { if !(y > wall_gap_y-4 && y < wall_gap_y+4) { let idx = build_data.map.xy_idx(30, y); build_data.map.tiles[idx] = TileType::Wall; build_data.map.tiles[idx-1] = TileType::Floor; let idx_right = build_data.map.xy_idx(build_data.width - 2, y); build_data.map.tiles[idx_right] = TileType::Wall; for x in 31 .. build_data.width-2 { let gravel_idx = build_data.map.xy_idx(x, y); build_data.map.tiles[gravel_idx] = TileType::Gravel; if y > 2 && y < build_data.height-1 { available_building_tiles.insert(gravel_idx); } } } else { for x in 30 .. build_data.width { let road_idx = build_data.map.xy_idx(x, y); build_data.map.tiles[road_idx] = TileType::Road; } } } build_data.take_snapshot(); for x in 30 .. build_data.width-1 { let idx_top = build_data.map.xy_idx(x, 1); build_data.map.tiles[idx_top] = TileType::Wall; let idx_bot = build_data.map.xy_idx(x, build_data.height-2); build_data.map.tiles[idx_bot] = TileType::Wall; } build_data.take_snapshot(); (available_building_tiles, wall_gap_y) } }
Again, let's step through how this works:
- We make a new
HashSet
calledavailable_building_tiles
. We'll be returning this so that other functions can use it later. - We set
wall_gap_y
to be a randomy
location on the map, between 6 andmap.height - 8
. We'll use this for the location of the road that runs through the town, and gates in the city walls. - We iterate the
y
axis on the map, skipping the very top and bottom-most tiles.- If
y
is outside of the "wall gap" (8 tiles centered onwall_gap_y
):- We draw a wall tile at location
30,y
and a road at29,y
. This gives a wall after the shore, and a clear gap in front of it (clearly they have lawn management employees!) - We also draw a wall at the far east of the map.
- We fill the intervening area with gravel.
- For tiles that gained gravel, we add them to the
available_building_tiles
set.
- We draw a wall tile at location
- If it is in the gap, we draw a road.
- If
- Lastly we fill rows
1
andheight-2
with walls between 30 andwidth-2
.
If you cargo run
now, you have the outline of a town:
Adding some buildings
A town without buildings is both rather pointless and rather unusual! So let's add some. We'll add another call to the builder function, this time passing the available_building_tiles
structure we created:
#![allow(unused)] fn main() { let mut buildings = self.buildings(rng, build_data, &mut available_building_tiles); }
The meat of the buildings code looks like this:
#![allow(unused)] fn main() { fn buildings(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap, available_building_tiles : &mut HashSet<usize>) -> Vec<(i32, i32, i32, i32)> { let mut buildings : Vec<(i32, i32, i32, i32)> = Vec::new(); let mut n_buildings = 0; while n_buildings < 12 { let bx = rng.roll_dice(1, build_data.map.width - 32) + 30; let by = rng.roll_dice(1, build_data.map.height)-2; let bw = rng.roll_dice(1, 8)+4; let bh = rng.roll_dice(1, 8)+4; let mut possible = true; for y in by .. by+bh { for x in bx .. bx+bw { if x < 0 || x > build_data.width-1 || y < 0 || y > build_data.height-1 { possible = false; } else { let idx = build_data.map.xy_idx(x, y); if !available_building_tiles.contains(&idx) { possible = false; } } } } if possible { n_buildings += 1; buildings.push((bx, by, bw, bh)); for y in by .. by+bh { for x in bx .. bx+bw { let idx = build_data.map.xy_idx(x, y); build_data.map.tiles[idx] = TileType::WoodFloor; available_building_tiles.remove(&idx); available_building_tiles.remove(&(idx+1)); available_building_tiles.remove(&(idx+build_data.width as usize)); available_building_tiles.remove(&(idx-1)); available_building_tiles.remove(&(idx-build_data.width as usize)); } } build_data.take_snapshot(); } } // Outline buildings let mut mapclone = build_data.map.clone(); for y in 2..build_data.height-2 { for x in 32..build_data.width-2 { let idx = build_data.map.xy_idx(x, y); if build_data.map.tiles[idx] == TileType::WoodFloor { let mut neighbors = 0; if build_data.map.tiles[idx - 1] != TileType::WoodFloor { neighbors +=1; } if build_data.map.tiles[idx + 1] != TileType::WoodFloor { neighbors +=1; } if build_data.map.tiles[idx-build_data.width as usize] != TileType::WoodFloor { neighbors +=1; } if build_data.map.tiles[idx+build_data.width as usize] != TileType::WoodFloor { neighbors +=1; } if neighbors > 0 { mapclone.tiles[idx] = TileType::Wall; } } } } build_data.map = mapclone; build_data.take_snapshot(); buildings } }
Once again, lets walk through this algorithm:
- We make a vector of tuples, each containing 4 integers. These are the building's
x
andy
coordinates, along with its size in each dimension. - We make a variable
n_buildings
to store how many we've placed, and loop until we have 12. For each building:- We pick a random
x
andy
position, and a randomwidth
andheight
for the building. - We set
possible
totrue
- and then loop over every tile in the candidate building location. If it isn't in theavailable_building_tiles
set, we setpossible to false
. - If
possible
is still true, we again loop over every tile - setting to be aWoodenFloor
. We then remove that tile, and all four surrounding tiles from theavailable_building_tiles
list - ensuring a gap between buildings. We also incrementn_buildings
, and add the building to a list of completed buildings.
- We pick a random
- Now we have 12 buildings, we take a copy of the map.
- We loop over every tile on in the "town" part of the map.
- For each tile, we count the number of neighboring tiles that aren't a
WoodenFloor
(in all four directions). - If the neighboring tile count is greater than zero, then we can place a wall here (because it must be the edge of a building). We write to our copy of the map - so as not to influence the check on subsequent tiles (otherwise, you'll have buildings replaced with walls).
- For each tile, we count the number of neighboring tiles that aren't a
- We put the copy back into our map.
- We return out list of placed buildings.
If you cargo run
now, you'll see that we have buildings!
Adding some doors
The buildings are great, but there are no doors. So you can't ever enter or exit them. We should fix that. Extend the builder function with another call:
#![allow(unused)] fn main() { let doors = self.add_doors(rng, build_data, &mut buildings, wall_gap_y); }
The add_doors
function looks like this:
#![allow(unused)] fn main() { fn add_doors(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap, buildings: &mut Vec<(i32, i32, i32, i32)>, wall_gap_y : i32) -> Vec<usize> { let mut doors = Vec::new(); for building in buildings.iter() { let door_x = building.0 + 1 + rng.roll_dice(1, building.2 - 3); let cy = building.1 + (building.3 / 2); let idx = if cy > wall_gap_y { // Door on the north wall build_data.map.xy_idx(door_x, building.1) } else { build_data.map.xy_idx(door_x, building.1 + building.3 - 1) }; build_data.map.tiles[idx] = TileType::Floor; build_data.spawn_list.push((idx, "Door".to_string())); doors.push(idx); } build_data.take_snapshot(); doors } }
This function is quite simple, but we'll step through it:
- We make a new vector of door locations; we'll need it later.
- For each building in our buildings list:
- Set
door_x
to a random point along the building's horizontal side, not including the corners. - Calculate
cy
to be the center of the building. - If
cy > wall_gap_y
(remember that one? Where the road is!), we place the door'sy
coordinate on the North side - sobuilding.1
. Otherwise, we place it on the south side -building.1 + building.3 - 1
(y
location plus height, minus one). - We set the door tile to be a
Floor
. - We add a
Door
to the spawn list. - We add the door to the doors vector.
- Set
- We return the doors vector.
If you cargo run
now, you'll see doors appear for each building:
Paths to doors
It would be nice to decorate the gravel with some paths to the various doors in the town. It makes sense - even wear and tear from walking to/from the buildings will erode a path. So we add another call to the builder function:
#![allow(unused)] fn main() { self.add_paths(build_data, &doors); }
The add_paths
function is a little long, but quite simple:
#![allow(unused)] fn main() { fn add_paths(&mut self, build_data : &mut BuilderMap, doors : &[usize]) { let mut roads = Vec::new(); for y in 0..build_data.height { for x in 0..build_data.width { let idx = build_data.map.xy_idx(x, y); if build_data.map.tiles[idx] == TileType::Road { roads.push(idx); } } } build_data.map.populate_blocked(); for door_idx in doors.iter() { let mut nearest_roads : Vec<(usize, f32)> = Vec::new(); let door_pt = rltk::Point::new( *door_idx as i32 % build_data.map.width as i32, *door_idx as i32 / build_data.map.width as i32 ); for r in roads.iter() { nearest_roads.push(( *r, rltk::DistanceAlg::PythagorasSquared.distance2d( door_pt, rltk::Point::new( *r as i32 % build_data.map.width, *r as i32 / build_data.map.width ) ) )); } nearest_roads.sort_by(|a,b| a.1.partial_cmp(&b.1).unwrap()); let destination = nearest_roads[0].0; let path = rltk::a_star_search(*door_idx, destination, &mut build_data.map); if path.success { for step in path.steps.iter() { let idx = *step as usize; build_data.map.tiles[idx] = TileType::Road; roads.push(idx); } } build_data.take_snapshot(); } } }
Let's walk through this:
- We start by making a
roads
vector, storing the map indices of every road tile on the map. We gather this by quickly scanning the map and adding matching tiles to our list. - Then we iterate through all the doors we've placed:
- We make another vector (
nearest_roads
) containing an index and a float. - We add each road, with its index and the calculated distance to the door.
- We sort the
nearest_roads
vector by the distances, ensuring that element0
will be the closest road position. Not that we're doing this for each door: if the nearest road is one we've added to another door, it will choose that one. - We call RLTK's a star pathing to find a route from the door to the nearest road.
- We iterate the path, writing a road tile at each location on the route. We also add it to the
roads
vector, so it will influence future paths.
- We make another vector (
If you cargo run
now, you'll see a pretty decent start for a town:
Start position and exit
We don't really want completely random start positions, nor an exit that is deliberately far away on this map. So we'll edit our TownBuilder
constructor to remove the additional meta-builders that provide this:
#![allow(unused)] fn main() { pub fn town_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { let mut chain = BuilderChain::new(new_depth, width, height); chain.start_with(TownBuilder::new()); chain } }
Now we have to modify our build
function to provide these instead. Placing the exit is easy - we want it to be to the East, on the road:
#![allow(unused)] fn main() { let exit_idx = build_data.map.xy_idx(build_data.width-5, wall_gap_y); build_data.map.tiles[exit_idx] = TileType::DownStairs; }
Placing the entrance is more difficult. We want the player to start their journey in the pub - but we haven't decided which building is the pub! We'll make the pub the largest building on the map. After all, it's the most important for the game! The following code will sort the buildings by size (in a building_size
vector, with the first tuple element being the building's index and the second being it's "square tileage"):
#![allow(unused)] fn main() { let mut building_size : Vec<(usize, i32)> = Vec::new(); for (i,building) in buildings.iter().enumerate() { building_size.push(( i, building.2 * building.3 )); } building_size.sort_by(|a,b| b.1.cmp(&a.1)); }
Not that we sorted in descending order (by doing b.cmp(&a)
rather than the other way around) - so the largest building is building 0
.
Now we can set the player's starting location:
#![allow(unused)] fn main() { // Start in the pub let the_pub = &buildings[building_size[0].0]; build_data.starting_position = Some(Position{ x : the_pub.0 + (the_pub.2 / 2), y : the_pub.1 + (the_pub.3 / 2) }); }
If you cargo run
now, you'll start in the pub - and be able to navigate an empty town to the exit:
Wrap-Up
This chapter has walked through how to use what we know about map generation to make a targeted procedural generation project - a fishing town. There's a river to the west, a road, town walls, buildings, and paths. It doesn't look bad at all for a starting point!
It is completely devoid of NPCs, props and anything to do. We'll rectify that in the next chapter.
The source code for this chapter may be found here
Run this chapter's example with web assembly, in your browser (WebGL2 required)
Copyright (C) 2019, Herbert Wolverson.