Rust Tut Roguelike Notsedovic PDF
Rust Tut Roguelike Notsedovic PDF
Introduction
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.
Every year, the �ne fellows over at r/roguelikedev run a Tutorial Tuesday series -
encouraging new programmers to join the ranks of roguelike developers. Most
languages end up being represented, and this year (2019) I decided that I'd use it as
an excuse to learn Rust. I didn't really want to use libtcod , the default engine - so I
created my own, RLTK. My initial entry into the series isn't very good, but I learned a
lot from it - you can �nd it here, if you are curious.
The series always points people towards an excellent series of tutorials, using Python
and libtcod . You can �nd it here. Section 1 of this tutorial mirrors the structure of
this tutorial - and tries to take you from zero (how do I open a console to say Hello Rust)
to hero (equipping items to �ght foes in a multi-level dungeon). I'm hoping to continue to
extend the series.
I also really wanted to use an Entity Component System. Rust has an excellent one
called Specs, so I went with it. I've used ECS-based setups in previous games, so it felt
natural to me to use it. It's also a cause of continual confusion on the subreddit, so
hopefully this tutorial can shine some light on its bene�ts and why you might want to
use one.
I've had a blast writing this - and hope to continue writing. Please feel free to contact
me (I'm @herberticus on Twitter) if you have any questions, ideas for improvements,
or things you'd like me to add. Also, sorry about all the Patreon spam - hopefully
someone will �nd this su�ciently useful to feel like throwing a co�ee or two my way.
:-)
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.
This tutorial is primarily about learning to make roguelikes (and by extension other
games), but it should also help you get used to Rust and RLTK - The Roguelike Tool Kit
we'll be using to provide input/output. Even if you don't want to use Rust, my hope is
that you can bene�t from the structure, ideas and general game development advice.
Why Rust?
Rust �rst appeared in 2010, but has only relatively recently hit "stable" status - that is,
code you write is pretty unlikely to stop working when the language changes now.
Development is very much ongoing, with whole new sections of the language (such as
the asynchronous system) still appearing/stabilizing. This tutorial will stay away from
the bleeding edge of development - it should be stable.
Rust was designed to be a "better systems language" - that is, low-level like C++ , but
with far fewer opportunities to shoot yourself in the foot, a focus on avoiding the
many "gotchas" that make C++ development di�cult, and a massive focus on memory
and thread safety: it's designed to be really di�cult to write a program that corrupts
its memory, or su�ers from race conditions (it's not impossible, but you have to try!).
It is rapidly gaining traction, with everyone from Mozilla to Microsoft showing interest
- and an ever expanding number of tools being written in it.
Rust is also designed to have a better ecosystem than C++. Cargo provides a
complete package manager (so do vcpkg , conan , etc. in C++ land, but cargo is well-
integrated), a complete build system (similar to cmake , make , meson , etc. - but
standardized). It doesn't run on as many platforms as C or C++, but the list is ever-
growing.
I tried Rust (after urging from friends), and found that while it doesn't replace C++ in
my daily toolbox - there are times that it really helped get a project out of the door.
It's syntax takes a bit of getting used to, but it really does drop in nicely to existing
infrastructure.
Learning Rust
If you've used other programming languages, then there's a lot of help available!
If you �nd that you need something that isn't in there, it's quite likely that someone
has written a crate ("package" in every other language, but cargo deals with crates...)
to help. Once you have a working environment, you can type
cargo search <my term> to look for crates that help. You can also head to crates.io
to see a full list of crates that are on o�er in Cargo - complete with documentation
and examples.
If you are completely new to programming, then a piece of bad news: Rust is a
relatively young language, so there isn't a lot of "learn programming from scratch with
Rust" material out there - yet. You may �nd it easier to start with a higher-level
language, and then move "down" (closer to the metal, as it were) to Rust. The
tutorials/guides linked above should get you started if you decide to take the plunge,
however.
Getting Rust
Better TOML : makes reading toml �les nice; Rust uses them a lot
C/C++ : uses the C++ debugger system to debug Rust code
Rust (rls) : not the fastest, but thorough syntax highlighting and error
checking as you go.
Once you've picked your environment, open up an editor and navigate to your new
folder (in VS Code, File -> Open Folder and choose the folder).
Creating a project
Now that you are in your chosen folder, you want to open a terminal/console window
there. In VS Code, this is Terminal -> New Terminal . Otherwise, open a command
line as normal and cd to your folder.
Rust has a built-in package manager called cargo . Cargo can make project templates
for you! So to create your new project, type cargo init hellorust . After a moment,
a new folder has appeared in your project - titled hellorust . It will contain the
following �les and directories:
src\main.rs
Cargo.toml
.gitignore
These are:
The .gitignore is handy if you are using git - it stops you from accidentally
putting �les into the git repository that don't need to be there. If you aren't using
git, you can ignore it.
src\main.rs is a simple Rust "hello world" program source.
Cargo.toml de�nes your project, and how it should be built.
fn main() {
println!("Hello, world!");
}
If you've used other programming languages, this should look somewhat familiar - but
the syntax/keywords are probably di�erent. Rust started out as a mashup between
ML and C, with the intent to create a �exible "systems" language (meaning: you can
write bare-metal code for your CPU without needing a virtual machine like Java or C#
do). Along the way, it inherited a lot of syntax from the two languages. I found the
syntax looked awful for the �rst week of using it, and came quite naturally after that.
Just like a human language, it takes a while for your brain to key into the syntax and
layout.
memory - and Rust will do the extra work to mark main as the �rst function. You
generally need a main function if you want your program to do anything, unless
you are making a library (a collection of functions for other programs to use).
3. The () is the function arguments or parameters. In this case, there aren't any -
so we just use empty opening and closing parentheses.
4. The { indicates the start of a block. In this case, the block is the body of the
function. Everything within the { and } is the content of the function:
instructions for it to run, in turn. Blocks also denote scope - so anything you
declare inside the function has its access limited to that function. In other words,
if you make a variable inside a function called cheese - it won't be visible from
inside a function called mouse (and vice versa). There are ways around this, and
we'll cover them as we build our game.
5. println! is a macro. You can tell Rust macros because they have an ! after
their name. You can learn all about macros here; for now, you just need to know
that they are special functions that are parsed into other code during compilation.
Printing to the screen can be quite complicated - you might want to say more
than "hello world" - and the println! macro covers a lot of formatting cases. (If
you are familiar with C++, it's equivalent to std::fmt . Most languages have their
own string formatting system, since programmers tend to have to output a lot of
text!)
6. The �nal } closes the block started in 4 .
Go ahead and type cargo run . After some compilation, if everything is working you
will be greeted with "Hello World" on your terminal.
Cargo is quite the tool! You can learn a bit about it from the Learn Rust book, and
everything about it from The Cargo Book if you are interested.
You'll be interacting with cargo a lot while you work in Rust. If you initialize your
program with cargo init , your program is a cargo crate. Compilation, testing,
running, updating - Cargo can help you with all of it. It even sets up git for you by
default.
cargo init creates a new project. That's what you used to make the hello
world program. If you really don't want to be using git , you can type
cargo init --vcs none (projectname) .
cargo build downloads all dependencies for a project and compiles them, and
then compiles your program. It doesn't actually run your program - but this is a
good way to quickly �nd compiler errors.
cargo update will fetch new versions of the crates you listed in your
cargo.toml �le (see below).
cargo clean can be used to delete all of the intermediate work �les for your
project, freeing up a bunch of disk space. They will automatically download and
recompile the next time you run/build your project. Occasionally, a cargo clean
can help when things aren't working properly - particularly IDE integration.
cargo verify-project will tell you if your Cargo settings are correct.
cargo install can be used to install programs via Cargo. This is helpful for
installing tools that you need.
Cargo also supports extensions - that is, plugins that make it do even more. There are
some that you may �nd particularly useful:
Cargo can reformat all your source code to look like standard Rust from the Rust
manuals. You need to type rustup component add rustfmt once to install the
tool. After that's done, you can type cargo fmt to format your code at any time.
If you'd like to work with the mdbook format - used for this book! - cargo can
help with that, too. Just once, you need to run cargo install mdbook to add the
tools to your system. After that, mdbook build will build a book project,
mdbook init will make a new one, and mdbook serve will give you a local
webserver to view your work! You can learn all about mdbook on their
documentation page.
Cargo can also integrate with a "linter" - called Clippy . Clippy is a little pedantic
(just like his Microsoft O�ce namesake!). Just the once, run
rustup component add clippy . You can now type cargo clippy at any time to
see suggestions for what may be wrong with your code!
Lets modify the newly created "hello world" project to make use of RLTK - the
Roguelike Toolkit.
Setup Cargo.toml
The auto-generated Cargo �le will look like this:
[package]
name = "helloworld"
version = "0.1.0"
authors = ["Your name if it knows it"]
edition = "2018"
[dependencies]
Go ahead and make sure that your name is correct! Next, we're going to ask Cargo to
use RLTK - the Roguelike toolkit library. Rust makes this very easy. Adjust the
dependencies section to look like this:
[dependencies]
rltk = { git = "https://github.com/thebracket/rltk_rs" }
We're telling it that the package is named rltk and giving it a Github location to pull
from.
It's a good idea to occasionally run cargo update - this will update the libraries used
by your program.
rltk::add_wasm_support!();
use rltk::{Rltk, GameState, Console};
struct State {}
impl GameState for State {
fn tick(&mut self, ctx : &mut Rltk) {
ctx.cls();
ctx.print(1, 1, "Hello Rust World");
}
}
fn main() {
let context = Rltk::init_simple8x8(80, 50, "Hello Rust World",
"resources");
let gs = State{ };
rltk::main_loop(context, gs);
}
Now create a new folder called resources . RLTK needs a few �les to run, and this is
where we put them. Download resources.zip, and unzip it into this folder. Be careful
to have resources/backing.fs (etc.) and not resources/resources/backing.fs .
Save, and go back to the terminal. Type cargo run , and you will be greeted with a
console window showing Hello Rust .
If you're new to Rust, you are probably wondering what exactly the Hello Rust code
does, and why it is there - so we'll take a moment to go through it.
State structure implements the trait GameState . Traits are like interfaces or
base classes in other languages: they setup a structure for you to implement in
your own code, which can then interact with the library that provides them -
without that library having to know anything else about your code. In this case,
GameState is a trait provided by RLTK. RLTK requires that you have one - it uses
it to call into your program on each frame. You can learn about traits in this
chapter of the Rust book.
5. fn tick(&mut self, ctx : &mut Rltk) is a function de�nition. We're inside the
trait implementation scope, so we are implementing the function for the trait -
so it has to match the type required by the trait. Functions are a basic building
block of Rust, I recommend the Rust book chapter on the topic.
1. In this case, fn tick means "make a function, called tick" (it's called "tick"
because it "ticks" with each frame that is rendered; it's common in game
programming to refer to each iteration as a tick).
2. It doesn't end with an -> type , so it is equivalent to a void function in C -
it doesn't return any data once called. The parameters can also bene�t
from a little explanation.
3. &mut self means "this function requires access to the parent structure,
and may change it" (the mut is short for "mutable" - meaning it can change
variables inside the structure - "state"). You can also have functions in a
structure that just have &self - meaning, we can see the content of the
structure, but can't change it. If you omit the &self altogether, the
function can't see the structure at all - but can be called as if the structure
was a namespace (you see this a lot with functions called new - they make a
new copy of the structure for you).
4. ctx: &mut Rltk means "pass in a variable called ctx " ( ctx is an
abbreviation for "context"). The colon indicates that we're specifying what
type of variable it must be.
5. & means "pass a reference" - which is a pointer to an existing copy of the
variable. The variable isn't copied, you are working on the version that was
passed in; if you make a change, you are changing the original. The Rust
Book explains this better than I can.
6. mut once again indicates that this is a "mutable" reference: you are
allowed to make changes to the context.
7. Finally Rltk is the type of the variable you are receiving. In this case, it's a
struct de�ned inside the RLTK library that provides various things you
can do to the screen.
6. ctx.cls(); says "call the cls function provided by the variable ctx . cls is a
common abbreviation for "clear the screen" - we're telling our context that it
should clear the virtual terminal. It's a good idea to do this at the beginning of a
frame, unless you speci�cally don't want to.
7. ctx.print(1, 1, "Hello Rust World"); is asking the context to print "Hello
Rust World" at the location (1,1).
8. Now we get to fn main() . Every program has a main function: it tells the
operating system where to start the program.
let context = Rltk::init_simple8x8(80, 50, "Hello Rust World",
9.
"resources");
is an example of calling a function from inside a struct - where that struct
doesn't take a "self" function. In other languages, this would be called a
constructor. We're calling the function init_simple8x8 (which is a helper
provided by RLTK to make a terminal using an 8 pixels by 8 pixels font). We ask
that the console dimensions be 80 characters wide, by 50 characters high. The
window title is "Hello Rust World". "resources" tells the program where to �nd
the resources folder we setup earlier.
10. let gs = State{ }; is an example of a variable assignment (see The Rust
Book). We're making a new variable called gs (short for "game state"), and
setting it to be a copy of the State struct we de�ned above.
11. rltk::main_loop(context, gs); calls into the rltk namespace, activating a
function called main_loop . It needs both the context and the GameState we
made earlier - so we pass those along. RLTK tries to take some of the complexity
of running a GUI/game application away, and provides this wrapper. The
function now takes over control of the program, and will call your tick function
(see above) every time the program "ticks" - that is, �nishes one cycle and moves
to the next. This can happen 60 or more times per second!
cd <path to tutorials>
git clone https://github.com/thebracket/rustrogueliketutorial .
After a while, this will download the complete tutorial (including the source code for
this book!). It is laid out as follows (this isn't complete!):
───book
├───chapter-01-hellorust
├───chapter-02-helloecs
├───chapter-03-walkmap
├───chapter-04-newmap
├───chapter-05-fov
├───resources
├───src
What's here?
The book folder contains the source code for this book. You can ignore it, unless
you feel like correcting my spelling!
Each chapter's example code is contained in chapter-xy-name folders; for
example, chapter-01-hellorust .
The src folder contains a simple script to remind you to change to a chapter
folder before running anything.
resources has the contents of the ZIP �le you downloaded for this example. All
the chapter folders are precon�gured to use this.
Cargo.toml is setup to include all of the tutorials as "workspace entries" - they
share dependencies, so it won't eat your whole drive re-downloading everything
each time you use it.
If you are using Visual Studio Code, you can instead use File -> Open Folder to open the
whole directory that you checked out. Using the inbuilt terminal, you can simply cd to
each example and cargo run it.
Getting Help
Feel free to contact me (I'm @herberticus on Twitter) if you have any questions,
ideas for improvements, or things you'd like me to add.
The �ne people on /r/rust are VERY helpful with Rust language issues.
The awesome people of /r/roguelikedev are VERY helpful when it comes to
Roguelike issues. Their Discord is pretty active, too.
Run this chapter's example with web assembly, in your browser (WebGL2 required)
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.
This chapter will introduce the entire of an Entity Component System (ECS), which will
form the backbone of the rest of this tutorial. Rust has a very good ECS, called Specs -
and this tutorial will show you how to use it, and try to demonstrate some of the early
bene�ts of using it.
BaseEntity
Monster
MeleeMob
OrcWarrior
ArcherMob
OrcArcher
You'd probably have something more complicated than that, but it works as an
illustration. BaseEntity would contain code/data required to appear on the map as
an entity, Monster indicates that it's a bad guy, MeleeMob would hold the logic for
�nding melee targets, closing in, and killing them. Likewise, ArcherMob would try to
maintain the optimal range and use their ranged weapon to �re from a safe distance.
The problem with a taxonomy like this is that it can be restrictive, and before you
know it - you are starting to write separate classes for more complicated
combinations. For example, what if we come up with an orc that can do both melee
and archery - and may become friendly if you've completed the Friends With The
Greenskins quest? You might well end up combining logic from all of them into one
special case class. It works - and plenty of games have published doing just that - but
what if there were an easier way?
Entity Component based design tries to eliminate the hierarchy, and instead
implement a set of "components" that describe what you want. An "entity" is a thing -
anything, really. An orc, a wolf, a potion, an Ethereal hard-drive formatting ghost -
whatever you want. It's also really simple: little more than an identi�cation number.
The magic comes from entities being able to have as many components as you want to
add. Components are just data, grouped by whatever properties you want to give an
entity.
For example, you could build the same set of mobs with components for: Position ,
Renderable , Hostile , MeleeAI , RangedAI , and some sort of CombatStats
component (to tell you about their weaponry, hit points, etc.). An Orc Warrior would
need a position so you know where they are, a renderable so you know how to draw
them. It's Hostile, so you mark it as such. Give it a MeleeAI and a set of game stats,
and you have everything you need to make it approach the player and try to hit them.
An Archer might be the same thing, but replacing MeleeAI with RangedAI. A hybrid
could keep all the components, but either have both AIs or an additional one if you
want custom behavior. If your orc becomes friendly, you could remove the Hostile
component - and add a Friendly one.
In other words: components are just like your inheritance tree, but instead of
inheriting traits you compose them by adding components until it does what you want.
This is often called "composition".
The "S" in ECS stands for "Systems". A System is a piece of code that gathers data from
the entity/components list and does something with it. It's actually quite similar to an
inheritance model, but in some ways it's "backwards". For example, drawing in an
OOP system is often: For each BaseEntity, call that entities Draw command. In an ECS
system, it would be Get all entities with a position and a renderable component, and use
that data to draw them.
For small games, an ECS often feels like it's adding a bit of extra typing to your code. It
is. You take the additional work up front, to make life easier later.
That's a lot to digest, so we'll look at a simple example of how an ECS can make your
life a bit easier.
[dependencies]
rltk = { git = "https://github.com/thebracket/rltk_rs" }
specs = "0.15.0"
specs-derive = "0.4.0"
This is pretty straight-forward: we're telling Rust that we still want to use RLTK, and
we're also asking for specs (the version number is current at the time of writing; you
can check for new ones by typing cargo search specs ). We're also adding
specs-derive - which provides some helper code to reduce the amount of
boilerplate typing you have to do.
The use rltk:: is a short-hand; you can type rltk::Console every time you want a
console; this tells Rust that we'd like to just type Console instead. The
use specs::prelude::* line is there so we aren't continually typing
specs::prelude::World when we just want World .
The command #[macro_use] is a little scarier looking; it just means "the next crate
will contain macro code, please use it". This exists to avoid the C++ problem of
#define commands leaking everywhere and confusing you. Rust is all about being
explicit, to avoid confusing yourself later!
Finally, we call extern crate specs_derive . This crate contains a bunch of helpers to
reduce the amount of typing you need. You'll see its bene�ts shortly. Rust 2018
doesn't require that you use an extern crate for every crate you use - but if you are
including macros, you have to use one with #[macro_use] - you are telling Rust that
you explicitly want macros from that crate.
So, we de�ne a struct (these are like structs in C, records in Pascal, etc. - a group of
data stored together. See the Rust Book chapter on Structures):
struct Position {
x: i32,
y: i32,
}
At this point, you could use Position s, but there's very little to help you store them
or assign them to anyone - so we need to tell Specs that this is a component. Specs
provides a lot of options for this, but we want to keep it simple. The long-form (no
specs-derive help) would look like this:
struct Position {
x: i32,
y: i32,
}
You will probably have a lot of components by the time your game is done - so that's a
lot of typing. Not only that, but it's lots of typing the same thing over and over - with
the potential to get confusing. Fortunately, specs-derive provides an easier way. You
can replace the previous code with:
#[derive(Component)]
struct Pos {
x: i32,
y: i32,
}
What does this do? #[derive(x)] is a macro that says "from my basic data, please
derive the boilerplate needed for x"; in this case, the x is a Component . The macro
generates the additional code for you, so you don't have to type it in for every
component. It makes it nice and easy to use components! The
#[macro_use] extern crate specs_derive; from earlier is making use of this; derive
macros are a special type of macro that implements additional functionality for a
structure on your behalf - saving lots of typing.
#[derive(Component)]
struct Renderable {
glyph: u8,
fg: RGB,
bg: RGB,
}
RGB comes from RLTK, and represents a color. That's why we have the
use rltk::{... RGB} statement - otherwise, we'd be typing rltk::RGB every time
there - saving keystrokes. Once again, this is a plain old data structure, and we are
using the derive macro to add the component storage information without having to
type it all out.
A World is an ECS, provided by the Rust crate Specs . You can have more than one if
you want, but we won't go there yet. We'll extend our State structure to have a place
to store the world:
struct State {
ecs: World
}
And now in main , when we create the world - we'll put an ECS into it:
Notice that World::new() is another constructor - it's a method inside the World
type, but without a reference to self . So it doesn't work on existing World objects -
it can only make new ones. This is a pattern used everywhere in Rust, so it's a good
idea to be familiar with it. The Rust Book has a section on the topic.
The next thing to do is to tell the ECS about the components we have created. We do
this right after we create the world:
gs.ecs.register::<Position>();
gs.ecs.register::<Renderable>();
What this does is it tells our World to take a look at the types we are giving it, and do
some internal magic to create storage systems for each of them. Specs has made this
easy; so long as it implements Component , you can put anything you like in as a
component!
Creating entities
Now we've got a World that knows how to store Position and Renderable
components. Having these components simply exist doesn't help us, beyond providing
an indication of structure. In order to use them, they need to be attached to
something in the game. In the ECS world, that something is called an entity. Entities
are quite simple; they are little more than an identi�cation number, telling the ECS
that an entity exists. They can have any combination of components attached to them.
In this case, we're going to make an entity that knows where it is on the screen, and
We can create an entity with both a Renderable and a Position component like this:
gs.ecs
.create_entity()
.with(Position { x: 40, y: 25 })
.with(Renderable {
glyph: rltk::to_cp437('@'),
fg: RGB::named(rltk::YELLOW),
bg: RGB::named(rltk::BLACK),
})
.build();
What this does, is it tells our World ( ecs in gs - our game state) that we'd like a new
entity. That entity should have a position (we've picked the middle of the console), and
we'd like it to be renderable with an @ symbol in yellow on black. That's very simple;
we aren't even storing the entity (we could if we wanted to) - we're just telling the
world that it's there!
Notice that we are using an interesting layout: lots of functions that don't end in an ;
to separate out the end of the statement, but instead lots of . calls to another
function. This is called the builder pattern, and is very common in Rust. Combining
functions in this fashion is called method chaining (a method is a function inside a
structure). It works because each function returns a copy of itself - so each function
runs in turn, passing itself as the holder for the next method in the chain. So in this
example, we start with a create_entity call - which returns a new, empty, entity. On
that entity, we call with - which attaches a component to it. That in turn returns the
partially built entity - so we can call with again to add the Renderable component.
Finally, .build() takes the assembled entity and does the hard part - actually putting
together all of the disparate parts into the right parts of the ECS for you.
You could easily add a bunch more entities, if you want. Lets do just that:
for i in 0..10 {
gs.ecs
.create_entity()
.with(Position { x: i * 7, y: 20 })
.with(Renderable {
glyph: rltk::to_cp437('☺'),
fg: RGB::named(rltk::RED),
bg: RGB::named(rltk::BLACK),
})
.build();
}
This is the �rst time we've called a for loop in the tutorial! If you've used other
programming languages, the concept will be familiar: run the loop with i set to every
value from 0 to 9. Wait - 9, you say? Rust ranges are exclusive - they don't include the
very last number in the range! This is for familiarity with languages like C which
normally write for (i=0; i<10; ++i) . If you actually want to go all the way to the
end of the range (so 0 to 10), you would write the rather cryptic for i in 0..=10 .
The Rust Book provides a great primer for understanding control �ow in Rust.
You'll notice that we're putting them at di�erent positions (every 7 characters, 10
times), and we've changed the @ to an ☺ - a smiley face ( to_cp437 is a helper RLTK
provides to let you type/paste Unicode and get the equivalent member of the old
DOS/CP437 character set. You could replace the to_cp437('☺') with a 1 for the
same thing). You can �nd the glyphs available here.
This line says join positions and renderables; like a database join, it only returns
entities that have both. It then uses Rust's "destructuring" to place each result (one
result per entity that has both components). So for each iteration of the for loop -
you get both components belonging to the same entity. That's enough to draw it!
The join function returns an iterator. The Rust Book has a great section on iterators.
In C++, iterators provide a begin , next and end function - and you can move
between elements in collections with them. Rust extends the same concept, only on
steroids: just about anything can be made into an iterator if you put your mind to it.
Iterators work very well with for loops - you can provide any iterator as the target in
for x in iterator loops. The 0..10 we discussed earlier really is a range - and
o�ers an iterator for Rust to navigate.
The other interesting thing here are the parentheses. In Rust, when you wrap
variables in brackets you are making a tuple. These are just a collection of variables,
grouped together - but without needing to go and make a structure just for this case.
You can access them individually via numeric access ( mytuple.0 , mytuple.1 , etc.) to
get to each �eld, or you can destructure them. (one, two) = (1, 2) sets the variable
one to 1 , and the variable two to 2 . That's what we're doing here: the join
We're running this for every entity that has both a Position and a Renderable
component. The join method is passing us both, guaranteed to belong to the same
enitity. Any entities that have one or the other - but not both - simply won't be
included in the data returned to us.
ctx is the instance of RLTK passed to us when tick runs. It o�ers a function called
set , that sets a single terminal character to the glyph/colors of your choice. So we
pass it the data from pos (the Position component for that entity), and the
colors/glyph from render (the Renderable component for that entity).
With that in place, any entity that has both a Position and a Renderable will be
rendered to the screen! You could add as many as you like, and they will render.
Remove one component or the other, and they won't be rendered (for example, if an
item is picked up you might remove its Position component - and add another
indicating that it's in your backpack; more on that in later tutorials)
#[derive(Component)]
struct Position {
x: i32,
y: i32,
}
#[derive(Component)]
struct Renderable {
glyph: u8,
fg: RGB,
bg: RGB,
}
struct State {
ecs: World
}
fn main() {
let context = Rltk::init_simple8x8(80, 50, "Hello Rust World",
"resources");
let mut gs = State {
ecs: World::new()
};
gs.ecs.register::<Position>();
gs.ecs.register::<Renderable>();
gs.ecs
.create_entity()
.with(Position { x: 40, y: 25 })
.with(Renderable {
glyph: rltk::to_cp437('@'),
fg: RGB::named(rltk::YELLOW),
bg: RGB::named(rltk::BLACK),
})
.build();
for i in 0..10 {
gs.ecs
.create_entity()
.with(Position { x: i * 7, y: 20 })
.with(Renderable {
glyph: rltk::to_cp437('☺'),
fg: RGB::named(rltk::RED),
bg: RGB::named(rltk::BLACK),
})
.build();
}
rltk::main_loop(context, gs);
}
First, we'll create a new component called LeftMover . Entities that have this
component are indicating that they really like going to the left. The component
de�nition is very simple; a component with no data like this is called a "tag
component". We'll put it up with our other component de�nitions:
#[derive(Component)]
struct LeftMover {}
Now we have to tell the ECS to use the type. With our other register calls, we add:
gs.ecs.register::<LeftMover>();
Now, lets only make the red smiley faces left movers. So their de�nition grows to:
for i in 0..10 {
gs.ecs
.create_entity()
.with(Position { x: i * 7, y: 20 })
.with(Renderable {
glyph: rltk::to_cp437('☺'),
fg: RGB::named(rltk::RED),
bg: RGB::named(rltk::BLACK),
})
.with(LeftMover{})
.build();
}
Notice how we've added one line: .with(LeftMover{}) - that's all it takes to add one
more component to these entities (and not the yellow @ ).
Now to actually make them move. We're going to de�ne our �rst system. Systems are a
way to contain entity/component logic together, and have them run independently.
There's lots of complex �exibility available, but we're going to keep it simple. Here's
everything required for our LeftWalker system:
struct LeftWalker {}
This isn't as nice/simple as I'd like, but it does make sense when you understand it.
Notice that this is very similar to how we wrote the rendering code - but instead of
calling in to the ECS, the ECS system is calling into our function/system. It can be a
tough judgment call on which to use. If your system just needs data from the ECS,
then a system is the right place to put it. If it also needs access to other parts of your
program, it is probably better implemented on the outside - calling in.
Now that we've written our system, we need to be able to use it. We'll add a
run_systems function to our State :
impl State {
fn run_systems(&mut self) {
let mut lw = LeftWalker{};
lw.run_now(&self.ecs);
self.ecs.maintain();
}
}
Finally, we actually want to run our systems. In the tick function, we add:
self.run_systems();
The nice thing is that this will run all systems we register into our dispatcher; so as we
add more, we don't have to worry about calling them (or even calling them in the right
order). You still sometimes need more access than the dispatcher has; our renderer
isn't a system because it needs the Context from RLTK (we'll improve that in a future
chapter).
#[derive(Component)]
struct Position {
x: i32,
y: i32,
}
#[derive(Component)]
struct Renderable {
glyph: u8,
fg: RGB,
bg: RGB,
}
#[derive(Component)]
struct LeftMover {}
struct State {
ecs: World,
}
self.run_systems();
struct LeftWalker {}
impl State {
fn run_systems(&mut self) {
let mut lw = LeftWalker{};
lw.run_now(&self.ecs);
self.ecs.maintain();
}
}
fn main() {
let context = Rltk::init_simple8x8(80, 50, "Hello Rust World",
"resources");
let mut gs = State {
ecs: World::new()
};
gs.ecs.register::<Position>();
gs.ecs.register::<Renderable>();
gs.ecs.register::<LeftMover>();
gs.ecs
.create_entity()
.with(Position { x: 40, y: 25 })
.with(Renderable {
glyph: rltk::to_cp437('@'),
fg: RGB::named(rltk::YELLOW),
bg: RGB::named(rltk::BLACK),
})
.build();
for i in 0..10 {
gs.ecs
.create_entity()
.with(Position { x: i * 7, y: 20 })
.with(Renderable {
glyph: rltk::to_cp437('☺'),
fg: RGB::named(rltk::RED),
bg: RGB::named(rltk::BLACK),
})
.with(LeftMover{})
.build();
}
rltk::main_loop(context, gs);
}
If you run it (with cargo run ), the red smiley faces zoom to the left, while the @
watches.
#[derive(Component, Debug)]
struct Player {}
gs.ecs.register::<Player>();
gs.ecs
.create_entity()
.with(Position { x: 40, y: 25 })
.with(Renderable {
glyph: rltk::to_cp437('@'),
fg: RGB::named(rltk::YELLOW),
bg: RGB::named(rltk::BLACK),
})
.with(Player{})
.build();
Drawing on our previous experience, we can see that this gains write access to
Player and Position . It then joins the two, ensuring that it will only work on entities
that have both component types - in this case, just the player. It then adds delta_x to
x and delta_y to y - and does some checks to make sure that you haven't tried to
leave the screen.
We'll add a second function to read the keyboard information provided by RLTK:
There's quite a bit of functionality here that we haven't seen before! The context is
providing information about a key - but the user may or may not be pressing one!
Rust provides a feature for this, called Option types. Option types have two possible
value: None (no data), or Some(x) - indicating that there is data here, held inside.
The context provides a key variable. It is an enumeration - that is, a variable that can
hold a value from a set of pre-de�ned values (in this case, keys on the keyboard). Rust
enumerations are really powerful, and can actually hold values as well - but we won't
use that yet.
So to get the data out of an Option , we need to unwrap it. There's a function called
unwrap - but if you call it when there isn't any data, your program will crash! So we'll
use Rust's match command to peek inside. Matching is one of Rust's strongest
bene�ts, and I highly recommend the Rust book chapter on it, or the Rust by Example
section if you prefer learning by examples.
So we call match ctx.key - and Rust expects us to provide a list of possibles matches.
In the case of ctx.key , there are only two possible values: Some or None . The
None => {} line says "match the case in which ctx.key has no data" - and runs an
empty block. Some(key) is the other option; there is some data - and we'll ask Rust to
give it to us as a variable named key (you can name it whatever you like).
We then match again, this time on the key. We have a line for each eventuality we
want to handle: VirtualKeyCode::Left => try_move_player(-1, 0, &mut gs.ecs)
says that if key equals VirtualKeyCode::Left ( VirtualKeyCode is the name of the
enumeration type), we should call our try_move_player function with (-1, 0). We
repeat that for all four directions. The _ => {} is rather odd looking; _ means
anything else. So we're telling Rust that any other key code can be ignored here. Rust is
rather pedantic: if you don't specify every possible enumeration, it will give a compiler
error! By including the default, we don't have to type every possible keystroke.
This function takes the current game state and context, looks at the key variable in
the context, and calls the appropriate move command if the relevant movement key is
pressed. Lastly, we add it into tick :
player_input(self, ctx);
If you run your program (with cargo run ), you now have a keyboard controlled @
symbol, while the smiley faces zoom to the left!
rltk::add_wasm_support!();
#[derive(Component)]
struct Position {
x: i32,
y: i32,
}
#[derive(Component)]
struct Renderable {
glyph: u8,
fg: RGB,
bg: RGB,
}
#[derive(Component)]
struct LeftMover {}
#[derive(Component, Debug)]
struct Player {}
struct State {
ecs: World
}
player_input(self, ctx);
self.run_systems();
struct LeftWalker {}
impl State {
fn run_systems(&mut self) {
let mut lw = LeftWalker{};
lw.run_now(&self.ecs);
self.ecs.maintain();
}
}
fn main() {
let context = Rltk::init_simple8x8(80, 50, "Hello Rust World",
"resources");
let mut gs = State {
ecs: World::new()
};
gs.ecs.register::<Position>();
gs.ecs.register::<Renderable>();
gs.ecs.register::<LeftMover>();
gs.ecs.register::<Player>();
gs.ecs
.create_entity()
.with(Position { x: 40, y: 25 })
.with(Renderable {
glyph: rltk::to_cp437('@'),
fg: RGB::named(rltk::YELLOW),
bg: RGB::named(rltk::BLACK),
})
.with(Player{})
.build();
for i in 0..10 {
gs.ecs
.create_entity()
.with(Position { x: i * 7, y: 20 })
.with(Renderable {
glyph: rltk::to_cp437('☺'),
fg: RGB::named(rltk::RED),
bg: RGB::named(rltk::BLACK),
})
.with(LeftMover{})
.build();
}
rltk::main_loop(context, gs);
}
This chapter was a lot to digest, but provides a really solid base on which to build. The
great part is: you've now got further than many aspiring developers! You have entities
on the screen, and can move around with the keyboard.
Run this chapter's example with web assembly, in your browser (WebGL2 required)
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.
A Roguelike without a map to explore is a bit pointless, so in this chapter we'll put
together a basic map, draw it, and let your player walk around a bit. We're starting
with the code from chapter 2, but with the red smiley faces (and their leftward
tendencies) removed.
Notice that we've included some derived features (more usage of derive macros, this
time built into Rust itself): Copy and Clone allow this to be used as a "value" type
(that is, it just passes around the value instead of pointers), and PartialEq allows us
to use == to see if two tile types match. If we didn't derive these features,
if tile_type == TileType::Wall would fail to compile!
This is simple: it multiplies the y position by the map width (80), and adds x . This
guarantees one tile per location, and e�ciently maps it in memory for left-to-right
reading.
We're using a Rust function shorthand here. Notice that the function returns a usize
(equivalent to size_t in C/C++ - whatever the basic size type used for a platform is) -
and the function body lacks a ; at the end? Any function that ends with a statement
that lacks a semicolon treats that line as a return statement. So it's the same as
typing return (y as usize * 80) + x as usize . This comes from the Rust author's
other favorite language, ML - which uses the same shorthand. It's considered
"Rustacean" (canonical Rust; I always picture a Rust Monster with cute little claws and
shell) to use this style, so we've adopted it for the tutorial.
for _i in 0..400 {
let x = rng.roll_dice(1, 79);
let y = rng.roll_dice(1, 49);
let idx = xy_idx(x, y);
if idx != xy_idx(40, 25) {
map[idx] = TileType::Wall;
}
}
map
}
There's a fair amount of syntax that we haven't encountered before here, so lets
break this down:
because it is trying to mirror the old D&D convention of dice being 1d20 or
similar. In this case, we should be glad that computers don't care about the
geometric di�culty of inventing a 79-sided dice! We also obtain a y value
between 1 and 49. We've rolled imaginary dice, and found a random location on
the map.
10. We set the variable idx (short for "index") to the vector index (via xy_idx we
de�ned earlier) for the coordinates we rolled.
11. if idx != xy_idx(40, 25) { checks that idx isn't the exact middle (we'll be
starting there, so we don't want to start inside a wall!).
12. If it isn't the middle, we set the randomly rolled location to be a wall.
It's pretty simple: it places walls around the outer edges of the map, and then adds
400 random walls anywhere that isn't the player's starting point.
gs.ecs.insert(new_map());
The map is now available from anywhere the ECS can see! Now inside your code, you
can access the map with the rather unwieldy
let map = self.ecs.get_mut::<Vec<TileType>>(); ; it's available to systems in an
easier fashion. There's actually several ways to get the value of map, including
ecs.get , ecs.fetch . get_mut obtains a "mutable" (you can change it) reference to
the map - wrapped in an optional (in case the map isn't there). fetch skips the
Option type and gives you a map directly. You can learn more about this in the Specs
Book.
This is mostly straightforward, and uses concepts we've already visited. In the
declaration, we pass the map as &[TileType] rather than &Vec<TileType> ; this
allows us to pass in "slices" (parts of) a map if we so choose. We won't do that yet, but
it may be useful later. It's also considered a more "rustic" (that is: idiomatic Rust) way
to do things, and the linter ( clippy ) warns about it. The Rust Book can teach you
about slices, if you are interested.
Otherwise, it takes advantage of the way we are storing our map - rows together, one
after the other. So it iterates through the entire map structure, adding 1 to the x
position for each tile. If it hits the map width, it zeroes x and adds one to y . This way
we aren't repeatedly reading all over the array - which can get slow. The actual
rendering is very simple: we match the tile type, and draw either a period or a hash
for walls/�oors.
The fetch call is new (we mentioned it above). fetch requires that you promise that
you know that the resource you are requesting really does exist - and will crash if it
doesn't. It doesn't quite return a reference - it's a shred type, which acts like a
reference most of the time but occasionally needs a bit of coercing to be one. We'll
worry about that bridge when it comes time to cross it, but consider yourself warned!
To accomplish this, we modify the try_move_player to read the map and check that
the destination is open:
The new parts are the let map = ... part, which uses fetch just the same way as
the main loop (this is the advantage of storing it in the ECS - you can get to it
everywhere without trying to coerce Rust into letting you use global variables!). We
calculate the cell index of the player's destination with
let destination_idx = xy_idx(pos.x + delta_x, pos.y + delta_y); - and if it isn't
Run the program ( cargo run ) now, and you have a player in a map - and can move
around, properly obstructed by walls.
rltk::add_wasm_support!();
#[derive(Component)]
struct Position {
x: i32,
y: i32,
}
#[derive(Component)]
struct Renderable {
glyph: u8,
fg: RGB,
bg: RGB,
}
#[derive(Component, Debug)]
struct Player {}
struct State {
ecs: World
}
for _i in 0..400 {
let x = rng.roll_dice(1, 79);
let y = rng.roll_dice(1, 49);
let idx = xy_idx(x, y);
if idx != xy_idx(40, 25) {
map[idx] = TileType::Wall;
}
}
map
}
}
}
player_input(self, ctx);
self.run_systems();
impl State {
fn run_systems(&mut self) {
self.ecs.maintain();
}
}
fn main() {
let context = Rltk::init_simple8x8(80, 50, "Hello Rust World",
"resources");
let mut gs = State {
ecs: World::new()
};
gs.ecs.register::<Position>();
gs.ecs.register::<Renderable>();
gs.ecs.register::<Player>();
gs.ecs.insert(new_map());
gs.ecs
.create_entity()
.with(Position { x: 40, y: 25 })
.with(Renderable {
glyph: rltk::to_cp437('@'),
fg: RGB::named(rltk::YELLOW),
bg: RGB::named(rltk::BLACK),
})
.with(Player{})
.build();
rltk::main_loop(context, gs);
}
Run this chapter's example with web assembly, in your browser (WebGL2 required)
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.
In this chapter, we'll make a more interesting map. It will be room-based, and look a
bit like many of the earlier roguelikes such as Moria - but with less complexity. It will
also provide a great starting point for placing monsters!
Cleaning up
We're going to start by cleaning up our code a bit, and utilizing separate �les. As
projects gain in complexity/size, it's a good idea to start keeping them as a clean set of
�les/modules, so we can quickly �nd what we're looking for (and improve compilation
times, sometimes).
If you look at the source code for this chapter, you'll see that we've broken out a lot of
functionality into individual �les. When you make a new �le in Rust, it automatically
becomes a module. You then have to tell Rust to use these modules, so main.rs has
gained a few mod map and similar, followed by pub use map::* . This says "import the
module map, and then use - and make available to other modules - its public
contents".
We've also made a bunch of struct into pub struct , and added pub to their
members. If you don't do this, then the structure remains internal to that module only
- and you can't use it in other parts of the code. This is the same as putting a public:
C++ line in a class de�nition, and exporting the type in the header. Rust makes it a bit
cleaner, and no need to write things twice!
Rust's documentation tags to publish what this function does, in case we forget later:
/// Makes a map with solid boundaries and 400 randomly placed walls. No
guarantees that it won't
/// look awful.
pub fn new_map_test() -> Vec<TileType> {
...
}
In canonical Rust, if you pre�x a function with comments starting with /// , it makes it
into a function comment. Your IDE will then show you your comment text when you
hover the mouse over the function header, and you can use Cargo's documentation
features to make pretty documentation pages for the system you are writing. It's
mostly handy if you plan on sharing your code, or working with others - but it's nice to
have!
So now, in the spirit of the original libtcod tutorial, we'll start making a map. Our goal
is to randomly place rooms, and join them together with corridors.
map
}
This makes a solid 80x50 map, with walls on all tiles - you can't move! We've kept the
function signature, so changing the map we want to use in main.rs just requires
changing gs.ecs.insert(new_map_test()); to
gs.ecs.insert(new_map_rooms_and_corridors()); . Once again we're using the vec!
macro to make our life easier - see the previous chapter for a discussion of how that
works.
Since this algorithm makes heavy use of rectangles, and a Rect type - we'll start by
making one in rect.rs . We'll include some utility functions that will be useful later on
in this chapter:
impl Rect {
pub fn new(x:i32, y: i32, w:i32, h:i32) -> Rect {
Rect{x1:x, y1:y, x2:x+w, y2:y+h}
}
There's nothing really new here, but lets break it down a bit:
1. We de�ne a struct called Rect . We added the pub tag to make it public - it's
available outside of this module (by putting it into a new �le, we automatically
created a code module; that's a built-in Rust way to compartmentalize your
code). Over in main.rs , we can add pub mod Rect to say "we use Rect , and
because we put a pub in front of it anything can get Rect from us as
super::rect::Rect . That's not very ergonomic to type, so a second line
use rect::Rect shortens that to super::Rect .
2. We make a new constructor, entitled new . It uses the return shorthand and
returns a rectangle based on the x , y , width and height we pass in.
3. We de�ne a member method, intersect . It has an &self , meaning it can see
into the Rect to which it is attached - but can't modify it (it's a "pure" function).
It returns a bool: true if the two rectangles overlap, false otherwise.
4. We de�ne center , also as a pure member method. It simply returns the
Notice that we are using for y in room.y1 +1 ..= room.y2 - that's an inclusive
range. We want to go all the way to the value of y2 , and not y2-1 ! Otherwise, it's
relatively straightforward: use two for loops to visit every tile inside the room's
rectangle, and set that tile to be a Floor .
With these two bits of code, we can create a new rectangle anywhere with
Rect::new(x, y, width, height) . We can add it to the map as �oors with
apply_room_to_map(rect, map) . That's enough to add a couple of test rooms. Our
map function now looks like this:
map
}
If you cargo run your project, you'll see that we now have two rooms - not linked
together.
Making a corridor
Two disconnected rooms isn't much fun, so lets add a corridor between them. We're
going to need some comparison functions, so we have to tell Rust to import them (at
the top of map.rs ): use std::cmp::{max, min}; . min and max do what they say:
they return the minimum or maximum of two values. You could use if statements to
do the same thing, but some computers will optimize this into a simple (FAST) call for
you; we let Rust �gure that out!
Then we add a call, apply_horizontal_tunnel(&mut map, 25, 40, 23); to our map
making function, and voila! We have a tunnel between the two rooms! If you run (
cargo run ) the project, you can walk between the two rooms - and not into walls. So
our previous code is still working, but now it looks a bit more like a roguelike.
for _i in 0..MAX_ROOMS {
let w = rng.range(MIN_SIZE, MAX_SIZE);
let h = rng.range(MIN_SIZE, MAX_SIZE);
let x = rng.roll_dice(1, 80 - w - 1) - 1;
let y = rng.roll_dice(1, 50 - h - 1) - 1;
let new_room = Rect::new(x, y, w, h);
let mut ok = true;
for other_room in rooms.iter() {
if new_room.intersect(other_room) { ok = false }
}
if ok {
apply_room_to_map(&new_room, &mut map);
rooms.push(new_room);
}
}
map
}
We've added const constants for the maximum number of rooms to make, and
the minimum and maximum size of the rooms. This is the �rst time we've
encountered const : it just says "setup this value at the beginning, and it can
never change". It's the only easy way to have global variables in Rust; since they
can never change, they often don't even exist and get baked into the functions
where you use them. If they do exist, because they can't change there are no
concerns when multiple threads access them. It's often cleaner to setup a
named constant than to use a "magic number" - that is, a hard-coded value with
no real clue as to why you picked that value.
We acquire a RandomNumberGenerator from RLTK (which required that we add to
the use statement at the top of map.rs )
Running the project ( cargo run ) at this point will give you a selection of random
rooms, with no corridors between them.
if ok {
apply_room_to_map(&new_room, &mut map);
if !rooms.is_empty() {
let (new_x, new_y) = new_room.center();
let (prev_x, prev_y) = rooms[rooms.len()-1].center();
if rng.range(0,1) == 1 {
apply_horizontal_tunnel(&mut map, prev_x, new_x, prev_y);
apply_vertical_tunnel(&mut map, prev_y, new_y, new_x);
} else {
apply_vertical_tunnel(&mut map, prev_y, new_y, prev_x);
apply_horizontal_tunnel(&mut map, prev_x, new_x, new_y);
}
}
rooms.push(new_room);
}
1. So what does this do? It starts by looking to see if the rooms list is empty. If it is,
then there is no previous room to join to - so we ignore it.
2. It gets the room's center, and stores it as new_x and new_y .
3. It gets the previous room in the vector's center, and stores it as prev_x and
prev_y .
4. It rolls a dice, and half the time it draws a horizontal and then vertical tunnel -
and half the time, the other way around.
Try cargo run now. It's really starting to look like a roguelike!
Our main.rs �le also requires adjustments, to accept the new format. We change our
main function in main.rs to:
fn main() {
let context = Rltk::init_simple8x8(80, 50, "Hello Rust World",
"resources");
let mut gs = State {
ecs: World::new()
};
gs.ecs.register::<Position>();
gs.ecs.register::<Renderable>();
gs.ecs.register::<Player>();
gs.ecs
.create_entity()
.with(Position { x: player_x, y: player_y })
.with(Renderable {
glyph: rltk::to_cp437('@'),
fg: RGB::named(rltk::YELLOW),
bg: RGB::named(rltk::BLACK),
})
.with(Player{})
.build();
rltk::main_loop(context, gs);
}
This is mostly the same, but we are receiving both the rooms list and the map from
new_map_rooms_and_corridors . We then place the player in the center of the �rst
room.
both hardcore UNIX users happy, and makes regular players happier.
We're not going to worry about diagonal movement yet. In player.rs , we change
player_input to look like this:
You should now get something like this when you cargo run your project:
Run this chapter's example with web assembly, in your browser (WebGL2 required)
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 a nicely drawn map, but it shows the whole dungeon! That reduces the
usefulness of exploration - if we already know where everything is, why bother
exploring? This chapter will add "�eld of view", and adjust rendering to show the parts
of the map we've already discovered. It will also refactor the map into its own
structure, rather than just a vector of tiles.
Map refactor
We'll keep map-related functions and data together, to keep things clear as we make
an ever-more-complicated game. The bulk of this is creating a new Map structure, and
moving our helper functions to its implementation.
impl Map {
pub fn xy_idx(&self, x: i32, y: i32) -> usize {
(y as usize * self.width as usize) + x as usize
}
{
self.tiles[idx as usize] = TileType::Floor;
}
}
}
for _i in 0..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);
let mut 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,1) == 1 {
map.apply_horizontal_tunnel(prev_x, new_x,
prev_y);
map.apply_vertical_tunnel(prev_y, new_y, new_x);
} else {
map.rooms.push(new_room);
}
}
map
}
}
There's changes in main and player , too - see the example source for all the details.
This has cleaned up our code quite a bit - we can pass a Map around, instead of a
vector. If we want to teach Map to do more things - we have a place to do so.
#[derive(Component)]
pub struct Viewshed {
pub visible_tiles : Vec<rltk::Point>,
pub range : i32
}
gs.ecs.register::<Viewshed>();
gs.ecs
.create_entity()
.with(Position { x: player_x, y: player_y })
.with(Renderable {
glyph: rltk::to_cp437('@'),
fg: RGB::named(rltk::YELLOW),
bg: RGB::named(rltk::BLACK),
})
.with(Player{})
.with(Viewshed{ visible_tiles : Vec::new(), range : 8 })
.build();
Player is getting quite complicated now - that's good, it shows what an ECS is good for!
impl State {
fn run_systems(&mut self) {
let mut vis = VisibilitySystem{};
vis.run_now(&self.ecs);
self.ecs.maintain();
}
}
mod visibility_system;
use visibility_system::VisibilitySystem;
This doesn't actually do anything, yet - but we've added a system into the dispatcher,
and as soon as we �esh out the code to actually plot the visibility, it will apply to every
entity that has both a Viewshed and a Position component.
This tells Rust that we are implementing Algorithm2D from RLTK (we also need to
adjust the use statement to
use rltk::{ RGB, Rltk, Console, RandomNumberGenerator, BaseMap,
Algorithm2D, Point };
). point2d_to_index is pretty much the same as the xy_idx function we've been
using: it returns the array index to which an x/y position points. index_to_point2d
does the same thing backwards: given an index, it returns the x/y coordinates it
references.
We also need to support BaseMap . We don't need all of it yet, so we're going to stub
parts of it out. In map.rs :
is_opaque simply returns true if the tile is a wall, and false otherwise. This will have
to be expanded if/when we add more types of tile, but works for now. We're not
touching get_available_exits yet - so we just return an empty list (this will be useful
in later chapters). get_pathing_distance is a simple distance calculation - so we
extract point locations from the two points and return a simple Pythagoras distance.
Again, this will be useful later.
There's quite a bit here, and the viewshed is actually the simplest part:
This will now run every frame (which is overkill, more on that later) - and store a list of
visible tiles.
If you run the example now ( cargo run ), it will show you just what the player can see.
There's no memory, and performance is quite awful - but it's there and about right.
It's clear that we're on the right track, but we need a more e�cient way to do things. It
would be nice if the player could remember the map as they see it, too.
To simulate map memory, we'll extend our Map class to include a revealed_tiles
structure. It's just a bool for each tile on the map - if true, then we know what's there.
Our Map de�nition now looks like this:
#[derive(Default)]
pub struct Map {
pub tiles : Vec<TileType>,
pub rooms : Vec<Rect>,
pub width : i32,
pub height : i32,
pub revealed_tiles : Vec<bool>
}
We also need to extend the function that �lls the map to include the new type. In
new_rooms_and_corridors , we extend the Map creation to:
We change the draw_map to look at this value, rather than iterating the component
each time. The function now looks like this:
let mut y = 0;
let mut x = 0;
for (idx,tile) in map.tiles.iter().enumerate() {
// Render a tile depending upon the tile type
if map.revealed_tiles[idx] {
match tile {
TileType::Floor => {
ctx.set(x, y, RGB::from_f32(0.5, 0.5, 0.5),
RGB::from_f32(0., 0., 0.), rltk::to_cp437('.'));
}
TileType::Wall => {
ctx.set(x, y, RGB::from_f32(0.0, 1.0, 0.0),
RGB::from_f32(0., 0., 0.), rltk::to_cp437('#'));
}
}
}
This will render a black screen, because we're never setting any tiles to be revealed!
So now we extend the VisibilitySystem to know how to mark tiles as revealed. To
do this, it has to check to see if an entity is the player - and if it is, it updates the map's
revealed status:
The main changes here are that we're getting the Entities list along with components,
and obtaining read-only access to the Players storage. We add those to the list of
things to iterate in the list, and add a let p : Option<&Player> = player.get(ent);
to see if this is the player. The rather cryptic if let Some(p) = p runs only if there is
a Player component. Then we calculate the index, and mark it revealed.
If you run ( cargo run ) the project now, it is MASSIVELY faster than the previous
version, and remembers where you've been.
#[derive(Component)]
pub struct Viewshed {
pub visible_tiles : Vec<rltk::Point>,
pub range : i32,
pub dirty : bool
}
We'll also update the initialization in main.rs to say that the viewshed is, in fact, dirty:
.with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true }) .
Our system can be extended to check if the dirty �ag is true, and only recalculate if
it is - and set the dirty �ag to false when it is done. Now we need to set the �ag
when the player moves - because what they can see has changed! We update
try_move_player in player.rs :
viewshed.dirty = true;
}
}
}
This should be pretty familiar by now: we've added viewsheds to get write storage,
and included it in the list of component types we are iterating. Then one call sets the
�ag to true after a move.
The game now runs very fast once more, if you type cargo run .
#[derive(Default)]
pub struct Map {
pub tiles : Vec<TileType>,
pub rooms : Vec<Rect>,
pub width : i32,
pub height : i32,
pub revealed_tiles : Vec<bool>,
pub visible_tiles : Vec<bool>
}
Our creation method also needs to know to add all false to it, just like before:
visible_tiles : vec![false; 80*50] . Next, in our VisibilitySystem we clear the
list of visible tiles before we begin iterating - and mark currently visible tiles as we �nd
them. So our code to run when updating the viewshed looks like this:
if viewshed.dirty {
viewshed.dirty = false;
viewshed.visible_tiles.clear();
viewshed.visible_tiles = field_of_view(Point::new(pos.x, pos.y),
viewshed.range, &*map);
Now we adjust the draw_map function to handle revealed but not currently visible
tiles di�erently. The new draw_map function looks like this:
let mut y = 0;
let mut x = 0;
for (idx,tile) in map.tiles.iter().enumerate() {
// Render a tile depending upon the tile type
if map.revealed_tiles[idx] {
let glyph;
let mut fg;
match tile {
TileType::Floor => {
glyph = rltk::to_cp437('.');
fg = RGB::from_f32(0.0, 0.5, 0.5);
}
TileType::Wall => {
glyph = rltk::to_cp437('#');
fg = RGB::from_f32(0., 1.0, 0.);
}
}
if !map.visible_tiles[idx] { fg = fg.to_greyscale() }
ctx.set(x, y, fg, RGB::from_f32(0., 0., 0.), glyph);
}
If you cargo run your project, you will now have visible tiles as slightly cyan �oors
and green walls - and grey as they move out of view. Performance should be great!
Congratulations - you now have a nice, working �eld-of-view system.
Run this chapter's example with web assembly, in your browser (WebGL2 required)
Chapter 6 - Monsters
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.
A roguelike with no monsters is quite unusual, so lets add some! The good news is
that we've already done some of the work for this: we can render them, and we can
calculate what they can see. We'll build on the source from the previous chapter, and
get some harmless monsters into play.
gs.ecs.insert(map);
Notice the skip(1) to ignore the �rst room - we don't want the player starting with a
mob on top of him/her/it! Running this (with cargo run ) produces something like
this:
That's a really good start! However, we're rendering monsters even if we can't see
them. We probably only want to render the ones we can see. We can do this by
modifying our render loop:
We get the map from the ECS, and use it to obtain an index - and check if the tile is
visible. If it is - we render the renderable. There's no need for a special case for the
player - since they can generally be expected to see themselves! The result is pretty
good:
gs.ecs.create_entity()
.with(Position{ x, y })
.with(Renderable{
glyph: glyph,
fg: RGB::named(rltk::RED),
bg: RGB::named(rltk::BLACK),
})
.with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true
})
.build();
}
Obviously, when we start adding in combat we'll want more variety - but it's a good
start. Run the program ( cargo run ), and you'll see a roughly 50/50 split between orcs
and goblins.
#[derive(Component, Debug)]
pub struct Monster {}
gs.ecs.create_entity()
.with(Position{ x, y })
.with(Renderable{
glyph: glyph,
fg: RGB::named(rltk::RED),
bg: RGB::named(rltk::BLACK),
})
.with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true })
.with(Monster{})
.build();
Now we make a system for monster thought. We'll make a new �le,
monster_ai_system.rs . We'll give it some basically non-existent intelligence:
Note that we're importing console from rltk - and printing with console::log .
This is a helper provided by RLTK that detects if you are compiling to a regular
program or a Web Assembly; if you are using a regular program, it calls println! and
outputs to the console. If you are in WASM , it outputs to the browser console.
impl State {
fn run_systems(&mut self) {
let mut vis = VisibilitySystem{};
vis.run_now(&self.ecs);
let mut mob = MonsterAI{};
mob.run_now(&self.ecs);
self.ecs.maintain();
}
}
The &["visibility_system"] is new - it says "run this after visibility, since we depend
upon its results. At this point, we don't actually care - but we will, so we'll put it in
there now.
If you cargo run your project now, it will be very slow - and your console will �ll up
with "Monster considers their own existence". The AI is running - but it's running every
tick!
Now, we change our tick function to only run the simulation when the game isn't
paused - and otherwise to ask for user input:
if self.runstate == RunState::Running {
self.systems.dispatch(&self.ecs);
self.runstate = RunState::Paused;
} else {
self.runstate = player_input(self, ctx);
}
As you can see, player_input now returns a state. Here's the new code for it:
If you launch cargo run now, the game is back up to speed - and the monsters only
think about what to do when you move. That's a basic turn-based tick loop!
You could let monsters think every time anything moves (and you probably will when
you get into deeper simulation), but for now lets quiet them down a bit - and have
them react if they can see the player.
It's highly likely that systems will often want to know where the player is - so lets add
that as a resource. In main.rs , one line puts it in (I don't recommend doing this for
non-player entities; there are only so many resources available - but the player is one
we use over and over again):
gs.ecs.insert(Point::new(player_x, player_y));
If you cargo run this, you'll be able to move around - and your console will gain
"Monster shouts insults" from time to time when a monster can see you.
#[derive(Component, Debug)]
pub struct Name {
pub name : String
}
We also register it in main.rs , which you should be comfortable with by now! We'll
also add some commands to add names to our monsters and the player. So our
monster spawner looks like this:
gs.ecs.create_entity()
.with(Position{ x, y })
.with(Renderable{
glyph: glyph,
fg: RGB::named(rltk::RED),
bg: RGB::named(rltk::BLACK),
})
.with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true
})
.with(Monster{})
.with(Name{ name: format!("{} #{}", &name, i) })
.build();
}
Now we adjust the monster_ai_system to include the monster's name. The new AI
looks like this:
If you cargo run the project, you now see things like Goblin #9 shouts insults - so you
can tell who is shouting.
And that's a wrap for chapter 6; we've added a variety of foul-mouthed monsters to
hurl insults at your fragile ego! In this chapter, we've begun to see some of the
bene�ts of using an Entity Component System: it was really easy to add newly
rendered monsters, with a bit of variety, and start storing names for things. The
Viewshed code we wrote earlier worked with minimal modi�cation to give visibility to
monsters - and our new monster AI was able to take advantage of what we've already
built to quite e�ciently say bad things to the player.
Run this chapter's example with web assembly, in your browser (WebGL2 required)
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 monsters, we want them to be more interesting than just yelling at
you on the console! This chapter will make them chase you, and introduce some basic
game stats to let you �ght your way through the hordes.
// Cardinal directions
if self.is_exit_valid(x-1, y) { exits.push((idx-1, 1.0)) };
if self.is_exit_valid(x+1, y) { exits.push((idx+1, 1.0)) };
if self.is_exit_valid(x, y-1) { exits.push((idx-self.width, 1.0)) };
if self.is_exit_valid(x, y+1) { exits.push((idx+self.width, 1.0)) };
exits
}
Pretty straight-forward: we evaluate each possible exit, and add it to the exits vector
if it can be taken. Next, we modify the main loop in monster_ai_system :
We've changed a few things to allow write access, requested access to the map. We've
also added an #[allow...] to tell the linter that we really did mean to use quite so
much in one type! The meat is the a_star_search call; RLTK includes a high-
performance A* implementation, so we're asking it for a path from the monster's
position to the player. Then we check that the path succeeded, and has more than 2
steps (step 0 is always the current location). If it does, then we move the monster to
that point - and set their viewshed to be dirty.
If you cargo run the project, monsters will now chase the player - and stop if they
lose line-of-sight. We're not preventing monsters from standing on each other - or you
- and we're not having them do anything other than yell at your console - but it's a
good start. It wasn't too hard to get chase mechanics in!
Blocking access
We don't want monsters to walk on top of each other, nor do we want them to get
stuck in a tra�c jam hoping to �nd the player; we'd rather they are willing to try and
�ank the player! We'll accompany this by keeping track of what parts of the map are
blocked.
#[derive(Default)]
pub struct Map {
pub tiles : Vec<TileType>,
pub rooms : Vec<Rect>,
pub width : i32,
pub height : i32,
pub revealed_tiles : Vec<bool>,
pub visible_tiles : Vec<bool>,
pub blocked : Vec<bool>
}
Lets introduce a new function to populate whether or not a tile is blocked. In the Map
implementation:
This function is very simple: it sets blocked for a tile to true if its a wall, false
otherwise (we'll expand it when we add more tile types). While we're working with
Map , lets adjust is_exit_valid to use this data:
This is quite straightforward: it checks that x and y are within the map, returning
false if the exit is outside of the map (this type of bounds checking is worth doing, it
prevents your program from crashing because you tried to read outside of the the
valid memory area). It then checks the index of the tiles array for the speci�ed
coordinates, and returns the inverse of blocked (the ! is the same as not in most
languages - so read it as "not blocked at idx "). While we're in map , there's one more
function we are going to need:
This is also quite simple: it iterates (visits) every vector in the tile_content list,
mutably (the iter_mut obtains a mutable iterator). It then tells each vector to clear
itself - remove all content (it doesn't actually guarantee that it will free up the
memory; vectors can keep empty sections ready for more data. This is actually a good
thing, because acquiring new memory is one of the slowest things a program can do -
so it helps keep things running fast).
Now we'll make a new component, BlocksTile . You should know the drill by now; in
Components.rs :
#[derive(Component, Debug)]
pub struct BlocksTile {}
gs.ecs.create_entity()
.with(Position{ x, y })
.with(Renderable{
glyph,
fg: RGB::named(rltk::RED),
bg: RGB::named(rltk::BLACK),
})
.with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true })
.with(Monster{})
.with(Name{ name: format!("{} #{}", &name, i) })
.with(BlocksTile{})
.build();
Lastly, we need to populate the blocked list. We'll probably extend this system later,
so we'll go with a nice generic name map_indexing_system.rs :
map.populate_blocked();
for (position, _blocks) in (&position, &blockers).join() {
let idx = map.xy_idx(position.x, position.y);
map.blocked[idx] = true;
}
}
}
This tells the map to setup blocking from the terrain, and then iterates all entities with
a BlocksTile component, and applies them to the blocked list. We need to register it
with run_systems ; in main.rs :
impl State {
fn run_systems(&mut self) {
let mut mapindex = MapIndexingSystem{};
mapindex.run_now(&self.ecs);
let mut vis = VisibilitySystem{};
vis.run_now(&self.ecs);
let mut mob = MonsterAI{};
mob.run_now(&self.ecs);
self.ecs.maintain();
}
}
We didn't specify any dependencies, we're relying upon Specs to �gure out if it can
run concurrently with anything. We do however add it to the dependency list for
MonsterAI - the AI relies on its results, so it has to be done.
If you cargo run now, monsters no longer end up on top of each other - but they do
end up on top of the player. We should �x that. We can make the monster only yell
when it is adjacent to the player. In monster_ai_system.rs , add this above the
visibility test:
Lastly, we want to stop the player from walking over monsters. In player.rs , we
replace the if statement that looks for walls with:
if !map.blocked[destination_idx] {
Since we already put walls into the blocked list, this should take care of the issue for
now. cargo run shows that monsters now block the player. They block them perfectly
- so a monster that wants to be in your way is an unpassable obstacle!
// Cardinal directions
if self.is_exit_valid(x-1, y) { exits.push((idx-1, 1.0)) };
if self.is_exit_valid(x+1, y) { exits.push((idx+1, 1.0)) };
if self.is_exit_valid(x, y-1) { exits.push((idx-self.width, 1.0)) };
if self.is_exit_valid(x, y+1) { exits.push((idx+self.width, 1.0)) };
// Diagonals
if self.is_exit_valid(x-1, y-1) { exits.push(((idx-self.width)-1,
1.45)); }
if self.is_exit_valid(x+1, y-1) { exits.push(((idx-self.width)+1,
1.45)); }
if self.is_exit_valid(x-1, y+1) { exits.push(((idx+self.width)-1,
1.45)); }
if self.is_exit_valid(x+1, y+1) { exits.push(((idx+self.width)+1,
1.45)); }
exits
}
VirtualKeyCode::Right |
VirtualKeyCode::Numpad6 |
VirtualKeyCode::L => try_move_player(1, 0, &mut gs.ecs),
VirtualKeyCode::Up |
VirtualKeyCode::Numpad8 |
VirtualKeyCode::K => try_move_player(0, -1, &mut gs.ecs),
VirtualKeyCode::Down |
VirtualKeyCode::Numpad2 |
VirtualKeyCode::J => try_move_player(0, 1, &mut gs.ecs),
// Diagonals
VirtualKeyCode::Numpad9 |
VirtualKeyCode::Y => try_move_player(1, -1, &mut gs.ecs),
VirtualKeyCode::Numpad7 |
VirtualKeyCode::U => try_move_player(-1, -1, &mut gs.ecs),
VirtualKeyCode::Numpad3 |
VirtualKeyCode::N => try_move_player(1, 1, &mut gs.ecs),
VirtualKeyCode::Numpad1 |
VirtualKeyCode::B => try_move_player(-1, 1, &mut gs.ecs),
You can now diagonally dodge around monsters - and they can move/attack
diagonally.
#[derive(Component, Debug)]
pub struct CombatStats {
pub max_hp : i32,
pub hp : i32,
pub defense : i32,
pub power : i32
}
Likewise, we'll give the monsters a weaker set of stats (we'll worry about monster
di�erentiation later):
#[derive(Default)]
pub struct Map {
pub tiles : Vec<TileType>,
pub rooms : Vec<Rect>,
pub width : i32,
pub height : i32,
pub revealed_tiles : Vec<bool>,
pub visible_tiles : Vec<bool>,
pub blocked : Vec<bool>,
pub tile_content : Vec<Vec<Entity>>
}
Then we'll upgrade the indexing system to index all entities by tile:
map.populate_blocked();
map.clear_content_index();
for (entity, position) in (&entities, &position).join() {
let idx = map.xy_idx(position.x, position.y);
We'll add a reader for CombatStats to the list of data-stores, and put in a quick
enemy detector:
If you cargo run this, you'll see that you can walk up to a mob and try to move onto
it. From Hell's Heart, I stab thee! appears on the console. So the detection works, and
the attack is in the right place.
#[derive(Component, Debug)]
pub struct WantsToMelee {
pub target : Entity
}
#[derive(Component, Debug)]
pub struct SufferDamage {
pub amount : i32
}
(Don't forget to register them in main.rs !). We modify the player's movement
command to create a component for the player when he/she/it wants to attack
someone:
...
if damage == 0 {
console::log(&format!("{} is unable to hurt {}",
&name.name, &target_name.name));
} else {
console::log(&format!("{} hits {}, for {} hp.",
&name.name, &target_name.name, damage));
inflict_damage.insert(wants_melee.target,
SufferDamage{ amount: damage }).expect("Unable to do damage");
}
}
}
}
wants_melee.clear();
}
And we'll need a damage_system to apply the damage (we're separating it out,
because damage could come from any number of sources!):
damage.clear();
}
}
This is called from our tick command, after the systems run:
damage_system::delete_the_dead(&mut self.ecs); .
If you cargo run now, you can run around the map hitting things - and they vanish
when dead!
We'll start o� by making the player entity into a game resource, so it can be easily
referenced. Like the player's position, it's something that we're likely to need all over
the place - and since entity IDs are stable, we can rely on it existing. In main.rs , we
change the create_entity for the player to return the entity object:
gs.ecs.insert(player_entity);
Now we modify the monster_ai_system . There's a bit of clean-up here, and the "hurl
insults" code is completely replaced with a single component insert:
map.blocked[idx] = true;
viewshed.dirty = true;
}
}
}
}
}
If you cargo run now, you can kill monsters - and they can attack you. If a monster
kills you - the game crashes! It crashes, because delete_the_dead has deleted the
player. That's obviously not what we intended. Here's a non-crashing version of
delete_the_dead :
give a speech), it's not the kind of tactical play that roguelikes encourage. The problem
is that our game state is just Running and Paused - and we aren't even running the
systems when the player acts. Additionally, systems don't know what phase we are in
- so they can't take that into account.
If you're running Visual Studio Code with RLS, half your project just turned red. That's
ok, we'll refactor one step at a time. We're going to remove the RunState altogether
from the main GameState :
This makes even more red appear! We're doing this, because we're going to make the
RunState into a resource. So in main.rs where we insert other resources, we add:
gs.ecs.insert(RunState::PreRun);
Now to start refactoring Tick . Our new tick function looks like this:
match newrunstate {
RunState::PreRun => {
self.run_systems();
newrunstate = RunState::AwaitingInput;
}
RunState::AwaitingInput => {
newrunstate = player_input(self, ctx);
}
RunState::PlayerTurn => {
self.run_systems();
newrunstate = RunState::MonsterTurn;
}
RunState::MonsterTurn => {
self.run_systems();
newrunstate = RunState::AwaitingInput;
}
}
{
let mut runwriter = self.ecs.write_resource::<RunState>();
*runwriter = newrunstate;
}
damage_system::delete_the_dead(&mut self.ecs);
draw_map(&self.ecs, ctx);
Notice how we now have a state machine going, with a "pre-run" phase for starting
the game! It's much cleaner, and quite obvious what's going on. There's a bit of scope
magic in use to keep the borrow-checker happy: if you declare and use a variable
inside a scope, it is dropped on scope exit (you can also manually drop things, but I
think this is cleaner looking).
In player.rs we simply replace all Paused with AwaitingInput , and Running with
PlayerTurn .
If you cargo run the project, it now behaves as you'd expect: the player moves, and
things he/she kills die before they can respond.
Wrapping Up
That was quite the chapter! We added in location indexing, damage, and killing things.
The good news is that this is the hardest part; you now have a simple dungeon bash
game! It's not particularly fun, and you will die (since there's no healing at all) - but the
basics are there.
Run this chapter's example with web assembly, in your browser (WebGL2 required)
User Interface
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.
Then we'll go through and change every reference to 80*50 to MAPCOUNT , and
references to the map size to use the constants. When this is done and running, we'll
change the MAPHEIGHT to 43 - to give us room at the bottom of the screen for a user
interface panel.
We'll create a new �le, gui.rs to hold our code. We'll go with a really minimal start:
We add a mod gui to the import block at the top of main.rs , and call it at the end of
tick :
gui::draw_ui(&self.ecs, ctx);
If we cargo run now, we'll see that the map has shrunk - and we have a white box in
place for the panel.
If you cargo run the project now, you'll see something like this:
Logging attacks
In our melee_combat_system , we add gamelog::GameLog to our imports from super ,
add a read/write accessor for the log ( WriteExpect<'a, GameLog>, ), and extend the
destructuring to include it:
let (entities, mut log, mut wants_melee, names, combat_stats, mut
inflict_damage) = data;
. Then it's just a matter of replacing the print! macros with inserting into the game
log. Here's the resultant code:
if damage == 0 {
log.entries.insert(0, format!("{} is unable to
hurt {}", &name.name, &target_name.name));
} else {
log.entries.insert(0, format!("{} hits {}, for {}
hp.", &name.name, &target_name.name, damage));
inflict_damage.insert(wants_melee.target,
SufferDamage{ amount: damage }).expect("Unable to do damage");
}
}
}
}
wants_melee.clear();
}
}
Now if you run the game and play a bit ( cargo run , playing is up to you!), you'll see
combat messages in the log:
Notifying of deaths
We can do the same thing with delete_the_dead to notify of deaths. Here's the
�nished code:
This sets the background of the cell at which the mouse is pointed to magenta. As you
can see, mouse information arrives from RLTK as part of the context.
Now we'll introduce a new function, draw_tooltips and call it at the end of draw_ui .
New new function looks like this:
if !tooltip.is_empty() {
let mut width :i32 = 0;
for s in tooltip.iter() {
if width < s.len() as i32 { width = s.len() as i32; }
}
width += 3;
if mouse_pos.0 > 40 {
let arrow_pos = Point::new(mouse_pos.0 - 2, mouse_pos.1);
let left_x = mouse_pos.0 - width;
let mut y = mouse_pos.1;
for s in tooltip.iter() {
ctx.print_color(left_x, y, RGB::named(rltk::WHITE),
RGB::named(rltk::GREY), &s.to_string());
let padding = (width - s.len() as i32)-1;
for i in 0..padding {
ctx.print_color(arrow_pos.x - i, y,
RGB::named(rltk::WHITE), RGB::named(rltk::GREY), &" ".to_string());
}
y += 1;
}
ctx.print_color(arrow_pos.x, arrow_pos.y,
RGB::named(rltk::WHITE), RGB::named(rltk::GREY), &"->".to_string());
} else {
let arrow_pos = Point::new(mouse_pos.0 + 1, mouse_pos.1);
let left_x = mouse_pos.0 +3;
let mut y = mouse_pos.1;
for s in tooltip.iter() {
ctx.print_color(left_x, y, RGB::named(rltk::WHITE),
RGB::named(rltk::GREY), &s.to_string());
let padding = (width - s.len() as i32)-1;
for i in 0..padding {
ctx.print_color(left_x + s.len() as i32 + i, y,
RGB::named(rltk::WHITE), RGB::named(rltk::GREY), &" ".to_string());
}
y += 1;
}
ctx.print_color(arrow_pos.x, arrow_pos.y,
RGB::named(rltk::WHITE), RGB::named(rltk::GREY), &"<-".to_string());
}
}
}
It starts by obtaining read access to the components we need for tooltips: names and
positions. It also gets read access to the map itself. Then we check that mouse cursor
is actually on the map, and bail out if it isn't - no point in trying to draw tooltips for
something that can never have any!
The remainder says "if we have any tooltips, look at the mouse position" - if its on the
left, we'll put the tooltip to the right, otherwise to the left.
If you choose to do this, the game looks a bit like the classic Caves of Qud:
Wrap up
Now that we have a GUI, it's starting to look pretty good!
Run this chapter's example with web assembly, in your browser (WebGL2 required)
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.
So far, we have maps, monsters, and bashing things! No roguelike "murder hobo"
experience would be complete without items to pick up along the way. This chapter
will add some basic items to the game, along with User Interface elements required to
pick them up, use them and drop them.
So... what makes up an item? Thinking about it, an item can be said to have the
following properties:
Consistently random
Computers are actually really bad at random numbers. Computers are inherently
deterministic - so (without getting into cryptographic stu�) when you ask for a
"random" number, you are actually getting a "really hard to predict next number in a
sequence". The sequence is controlled by a seed - with the same seed, you always get
the same dice rolls!
In main.rs , we add:
gs.ecs.insert(rltk::RandomNumberGenerator::new());
We can now access the RNG whenever we need it, without having to pass one around.
Since we're not creating a new one, we can start it with a seed (we'd use seeded
instead of new , and provide a seed). We'll worry about that later; for now, it's just
going to make our code cleaner!
Improved Spawning
One monster per room, always in the middle, makes for rather boring play. We also
need to support spawning items as well as monsters!
: S) {
ecs.create_entity()
.with(Position{ x, y })
.with(Renderable{
glyph,
fg: RGB::named(rltk::RED),
bg: RGB::named(rltk::BLACK),
})
.with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true
})
.with(Monster{})
.with(Name{ name : name.to_string() })
.with(BlocksTile{})
.with(CombatStats{ max_hp: 16, hp: 16, defense: 1, power: 4 })
.build();
}
As you can see, we've taken the existing code in main.rs - and wrapped it up in
functions in a di�erent module. We don't have to do this - but it helps keep things tidy.
Since we're going to be expanding our spawning, it's nice to keep things separated
out. Now we modify main.rs to use it:
gs.ecs.insert(rltk::RandomNumberGenerator::new());
for room in map.rooms.iter().skip(1) {
let (x,y) = room.center();
spawner::random_monster(&mut gs.ecs, x, y);
}
That's de�nitely tidier! cargo run will give you exactly what we had at the end of the
previous chapter.
for _i in 0 .. num_monsters {
let mut added = false;
while !added {
let x = (room.x1 + rng.roll_dice(1, i32::abs(room.x2 -
room.x1))) as usize;
let y = (room.y1 + rng.roll_dice(1, i32::abs(room.y2 -
room.y1))) as usize;
let idx = (y * MAPWIDTH) + x;
if !monster_spawn_points.contains(&idx) {
monster_spawn_points.push(idx);
added = true;
}
}
}
}
This obtains the RNG and the map, and rolls a dice for how many monsters it should
spawn. It then keeps trying to add random positions that aren't already occupied,
until su�cient monsters have been created. Each monster is then spawned at the
determined location. The borrow checker isn't at all happy with the idea that we
mutably access rng , and then pass the ECS itself along: so we introduce a scope to
keep it happy (automatically dropping access to the RNG when we are done with it).
If you cargo run the project now, it will have between 0 and 4 monsters per room. It
can get a little hairy!
#[derive(Component, Debug)]
pub struct Item {}
#[derive(Component, Debug)]
pub struct Potion {
pub heal_amount : i32
}
gs.ecs.register::<Item>();
gs.ecs.register::<Potion>();
Now we can modify the spawner code to also have a chance to spawn between 0 and
2 items:
for _i in 0 .. num_monsters {
let mut added = false;
while !added {
let x = (room.x1 + rng.roll_dice(1, i32::abs(room.x2 -
room.x1))) as usize;
let y = (room.y1 + rng.roll_dice(1, i32::abs(room.y2 -
room.y1))) as usize;
let idx = (y * MAPWIDTH) + x;
if !monster_spawn_points.contains(&idx) {
monster_spawn_points.push(idx);
added = true;
}
}
}
for _i in 0 .. num_items {
let mut added = false;
while !added {
let x = (room.x1 + rng.roll_dice(1, i32::abs(room.x2 -
room.x1))) as usize;
let y = (room.y1 + rng.roll_dice(1, i32::abs(room.y2 -
room.y1))) as usize;
let idx = (y * MAPWIDTH) + x;
if !item_spawn_points.contains(&idx) {
item_spawn_points.push(idx);
added = true;
}
}
}
}
If you cargo run the project now, rooms now sometimes contain health potions.
Tooltips and rendering "just work" - because they have the components required to
use them.
Picking Up Items
Having potions exist is a great start, but it would be helpful to be able to pick them up!
We'll create a new component in components.rs (and register it in main.rs !), to
represent an item being in someone's backpack:
#[derive(Component, Debug)]
pub struct InBackpack {
pub owner : Entity
}
We also want to make item collection generic - that is, any entity can pick up an item.
It would be pretty straightforward to just make it work for the player, but later on we
might decide that monsters can pick up loot (introducing a whole new tactical element
- bait!). So we'll also make a component indicating intent in components.rs (and
register it in main.rs ):
#[derive(Component, Debug)]
pub struct WantsToPickupItem {
pub collected_by : Entity,
pub item : Entity
}
Next, we'll put together a system to process WantsToPickupItem notices. We'll make a
new �le, inventory_system.rs :
if pickup.collected_by == *player_entity {
gamelog.entries.insert(0, format!("You pick up the {}.",
names.get(pickup.item).unwrap().name));
}
}
wants_pickup.clear();
}
}
This iterates the requests to pick up an item, removes their position component, and
adds an InBackpack component assigned to the collector. Don't forget to add it to
the systems list in main.rs :
The next step is to add an input command to pick up an item. g is a popular key for
this, so we'll go with that (we can always change it!). In player.rs , in the ever-growing
match statement of inputs, we add:
match target_item {
None => gamelog.entries.insert(0, "There is nothing here to pick
up.".to_string()),
Some(item) => {
let mut pickup = ecs.write_storage::<WantsToPickupItem>();
pickup.insert(*player_entity, WantsToPickupItem{ collected_by:
*player_entity, item }).expect("Unable to insert want to pickup");
}
}
}
This obtains a bunch of references/accessors from the ECS, and iterates all items with
a position. If it matches the player's position, target_item is set. Then, if
target_item is none - we tell the player that there is nothing to pick up. If it isn't, it
adds a pickup request for the system we just added to use.
If you cargo run the project now, you can press g anywhere to be told that there's
nothing to get. If you are standing on a potion, it will vanish when you press g ! It's in
our backpack - but we haven't any way to know that other than the log entry.
The i key is a popular choice for inventory ( b is also popular!), so in player.rs we'll
add the following to the player input code:
RunState::ShowInventory => {
if gui::show_inventory(self, ctx) == gui::ItemMenuResult::Cancel {
newrunstate = RunState::AwaitingInput;
}
}
let mut j = 0;
for (_pack, name) in (&backpack, &names).join().filter(|item|
item.0.owner == *player_entity ) {
ctx.set(17, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK),
rltk::to_cp437('('));
ctx.set(18, y, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK),
97+j as u8);
ctx.set(19, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK),
rltk::to_cp437(')'));
ctx.print(21, y, &name.name.to_string());
y += 1;
j += 1;
}
match ctx.key {
None => ItemMenuResult::NoResponse,
Some(key) => {
match key {
VirtualKeyCode::Escape => { ItemMenuResult::Cancel }
_ => ItemMenuResult::NoResponse
}
}
}
This starts out by using the filter feature of Rust iterators to count all items in your
backpack. It then draws an appropriately sized box, and decorates it with a title and
instructions. Next, it iterates all matching items and renders them in a menu format.
Finally, it waits for keyboard input - and if you pressed ESCAPE , indicates that it is time
to close the menu.
If you cargo run your project now, you can see items that you have collected:
Using Items
Now that we can display our inventory, lets make selecting an item actually use it.
We'll extend the menu to return both an item entity and a result:
ctx.print(21, y, &name.name.to_string());
equippable.push(entity);
y += 1;
j += 1;
}
match ctx.key {
None => (ItemMenuResult::NoResponse, None),
Some(key) => {
match key {
VirtualKeyCode::Escape => { (ItemMenuResult::Cancel, None)
}
_ => {
let selection = rltk::letter_to_option(key);
if selection > -1 && selection < count as i32 {
return (ItemMenuResult::Selected,
Some(equippable[selection as usize]));
}
(ItemMenuResult::NoResponse, None)
}
}
}
}
}
RunState::ShowInventory => {
let result = gui::show_inventory(self, ctx);
match result.0 {
gui::ItemMenuResult::Cancel => newrunstate =
RunState::AwaitingInput,
gui::ItemMenuResult::NoResponse => {}
gui::ItemMenuResult::Selected => {
let item_entity = result.1.unwrap();
let names = self.ecs.read_storage::<Name>();
let mut gamelog = self.ecs.fetch_mut::<gamelog::GameLog>();
gamelog.entries.insert(0, format!("You try to use {}, but it
isn't written yet", names.get(item_entity) .unwrap().name));
newrunstate = RunState::AwaitingInput;
}
}
}
If you try to use an item in your inventory now, you'll get a log entry that you try to use
it, but we haven't written that bit of code yet. That's a start!
Once again, we want generic code - so that eventually monsters might use potions.
We're going to cheat a little while all items are potions, and just make a potion system;
we'll turn it into something more useful later. So we'll start by creating an "intent"
component in components.rs (and registered in main.rs ):
#[derive(Component, Debug)]
pub struct WantsToDrinkPotion {
pub potion : Entity
}
wants_drink.clear();
}
}
Like other systems we've looked at, this iterates all of the WantsToDrinkPotion intent
objects. It then heals up the drinker by the amount set in the Potion component, and
deletes the potion. Since all of the placement information is attached to the potion
itself, there's no need to chase around making sure it is removed from the
appropriate backpack: the entity ceases to exist, and takes its components with it.
Testing this with cargo run gives a surprise: the potion isn't deleted after use! This is
because the ECS simply marks entities as dead - it doesn't delete them in systems (so
as to not mess up iterators and threading). So after every call to dispatch , we need
to add a call to maintain . In main.ecs :
RunState::PreRun => {
self.run_systems();
self.ecs.maintain();
newrunstate = RunState::AwaitingInput;
}
...
RunState::PlayerTurn => {
self.run_systems();
self.ecs.maintain();
newrunstate = RunState::MonsterTurn;
}
RunState::MonsterTurn => {
self.run_systems();
self.ecs.maintain();
newrunstate = RunState::AwaitingInput;
}
RunState::ShowInventory => {
let result = gui::show_inventory(self, ctx);
match result.0 {
gui::ItemMenuResult::Cancel => newrunstate =
RunState::AwaitingInput,
gui::ItemMenuResult::NoResponse => {}
gui::ItemMenuResult::Selected => {
let item_entity = result.1.unwrap();
let mut intent = self.ecs.write_storage::
<WantsToDrinkPotion>();
intent.insert(*self.ecs.fetch::<Entity>(),
WantsToDrinkPotion{ potion: item_entity }).expect("Unable to insert
intent");
newrunstate = RunState::PlayerTurn;
}
}
}
NOW if you cargo run the project, you can pickup and drink health potions:
Dropping Items
147 of 856 2019-11-02, 3:59 p.m.
Roguelike Tutorial - In Rust https://bfnightly.bracketproductions.com/rustbook...
You probably want to be able to drop items from your inventory, especially later when
they can be used as bait. We'll follow a similar pattern for this section - create an
intent component, a menu to select it, and a system to perform the drop.
#[derive(Component, Debug)]
pub struct WantsToDropItem {
pub item : Entity
}
if entity == *player_entity {
gamelog.entries.insert(0, format!("You drop up the {}.",
names.get(to_drop.item).unwrap().name));
}
}
wants_drop.clear();
}
}
ctx.print(21, y, &name.name.to_string());
equippable.push(entity);
y += 1;
j += 1;
}
match ctx.key {
None => (ItemMenuResult::NoResponse, None),
Some(key) => {
match key {
VirtualKeyCode::Escape => { (ItemMenuResult::Cancel, None)
}
_ => {
let selection = rltk::letter_to_option(key);
if selection > -1 && selection < count as i32 {
return (ItemMenuResult::Selected,
Some(equippable[selection as usize]));
}
(ItemMenuResult::NoResponse, None)
}
}
}
}
}
RunState::ShowDropItem => {
let result = gui::drop_item_menu(self, ctx);
match result.0 {
gui::ItemMenuResult::Cancel => newrunstate =
RunState::AwaitingInput,
gui::ItemMenuResult::NoResponse => {}
gui::ItemMenuResult::Selected => {
let item_entity = result.1.unwrap();
let mut intent = self.ecs.write_storage::<WantsToDropItem>();
intent.insert(*self.ecs.fetch::<Entity>(), WantsToDropItem{
item: item_entity }).expect("Unable to insert intent");
newrunstate = RunState::PlayerTurn;
}
}
}
If you cargo run the project, you can now press d to drop items! Here's a shot of
rather unwisely dropping a potion while being mobbed:
Render order
You've probably noticed by now that when you walk over a potion, it renders over the
top of you - removing the context for your player completely! We'll �x that by adding a
render_order �eld to Renderables :
#[derive(Component)]
pub struct Renderable {
pub glyph: u8,
pub fg: RGB,
pub bg: RGB,
pub render_order : i32
}
Your IDE is probably now highlighting lots of errors for Renderable components that
were created without this information. We'll add it to various places: the player is 0
(render �rst), monsters 1 (second) and items 2 (last). For example, in the Player
spawner, the Renderable now looks like this:
.with(Renderable {
glyph: rltk::to_cp437('@'),
fg: RGB::named(rltk::YELLOW),
bg: RGB::named(rltk::BLACK),
render_order: 0
})
To make this do something, we go to our item rendering code in main.rs and add a
sort to the iterators. We referenced the Book of Specs for how to do this! Basically, we
obtain the joined set of Position and Renderable components, and collect them
into a vector. We then sort that vector, and iterate it to render in the appropriate
order. In main.rs , replace the previous entity rendering code with:
Wrap Up
This chapter has shown a fair amount of the power of using an ECS: picking up, using
and dropping entities is relatively simple - and once the player can do it, so can
anything else (if you add it to their AI). We've also shown how to order ECS fetches, to
maintain a sensible render order.
Run this chapter's example with web assembly, in your browser (WebGL2 required)
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.
In the last chapter, we added items and inventory - and a single item type, a health
potion. Now we'll add a second item type: a scroll of magic missile, that lets you zap an
entity at range.
#[derive(Component, Debug)]
pub struct Consumable {}
Having this item indicates that using it destroys it (consumed on use). So we replace
the always-called entities.delete(useitem.item).expect("Delete failed"); in our
PotionUseSystem (which we rename ItemUseSystem !) with:
This is quite simple: check if the component has a Consumable tag, and destroy it if it
does. Likewise, we can replace the Potion section with a ProvidesHealing to
#[derive(Component, Debug)]
pub struct ProvidesHealing {
pub heal_amount : i32
}
Drawing that together, our code for creating a potion (in spawner.rs ) looks like this:
So we're describing where it is, what it looks like, its name, denoting that it is an item,
consumed on use, and provides 8 points of healing. This is nice and descriptive - and
future items can mix/match. As we add components, the item system will become
more and more �exible.
#[derive(Component, Debug)]
pub struct Ranged {
pub range : i32
}
#[derive(Component, Debug)]
pub struct InflictsDamage {
pub damage : i32
}
That neatly lays out the properties of what makes it tick: it has a position, an
appearance, a name, it's an item that is destroyed on use, it has a range of 6 tiles and
in�icts 8 points of damage. That's what I like about components: after a while, it
sounds more like you are describing a blueprint for a device than writing many lines
of code!
Replace the call to health_potion in the item spawning code with a call to
random_item .
If you run the program (with cargo run ) now, you'll �nd scrolls as well as potions
lying around. The components system already provides quite a bit of functionality:
You can see them rendered on the map (thanks to the Renderable and
Position )
You can pick them up and drop them (thank to Item )
You can list them in your inventory
You can call use on them, and they are destroyed: but nothing happens.
We'll extend our handler for ShowInventory in main.rs to handle items that are
ranged and induce a mode switch:
RunState::ShowInventory => {
let result = gui::show_inventory(self, ctx);
match result.0 {
gui::ItemMenuResult::Cancel => newrunstate =
RunState::AwaitingInput,
gui::ItemMenuResult::NoResponse => {}
gui::ItemMenuResult::Selected => {
let item_entity = result.1.unwrap();
let is_ranged = self.ecs.read_storage::<Ranged>();
let is_item_ranged = is_ranged.get(item_entity);
if let Some(is_item_ranged) = is_item_ranged {
newrunstate = RunState::ShowTargeting{ range:
is_item_ranged.range, item: item_entity };
} else {
let mut intent = self.ecs.write_storage::
<WantsToUseItem>();
intent.insert(*self.ecs.fetch::<Entity>(), WantsToUseItem{
item: item_entity }).expect("Unable to insert intent");
newrunstate = RunState::PlayerTurn;
}
}
}
}
So now in main.rs , where we match the appropriate game mode, we can stub in:
pub fn ranged_target(gs : &mut State, ctx : &mut Rltk, range : i32) ->
(ItemMenuResult, Option<Point>) {
let player_entity = gs.ecs.fetch::<Entity>();
let player_pos = gs.ecs.fetch::<Point>();
let viewsheds = gs.ecs.read_storage::<Viewshed>();
ctx.print_color(5, 0, RGB::named(rltk::YELLOW),
RGB::named(rltk::BLACK), "Select Target:");
(ItemMenuResult::NoResponse, None)
So we start by obtaining the player's location and viewshed, and iterating cells they
can see. We check the range of the cell versus the range of the item, and if it is in
range - we highlight the cell in blue. We also maintain a list of what cells are possible
to target. Then, we get the mouse position; if it is pointing at a valid target, we light it
up in cyan - otherwise we use red. If you click a valid cell, it returns targeting
information for where you are aiming - otherwise, it cancels.
#[derive(Component, Debug)]
pub struct WantsToUseItem {
pub item : Entity,
pub target : Option<rltk::Point>
}
So now when you receive a WantsToUseItem , you can now that the user is the owning
entity, the item is the item �eld, and it is aimed at target - if there is one (targeting
doesn't make much sense for healing potions!).
used_item = true;
}
}
}
If you cargo run the game, you can now blast entities with your magic missile scrolls!
#[derive(Component, Debug)]
pub struct AreaOfEffect {
pub radius : i32
}
So now we can write a fireball_scroll function to actually spawn them. This is a lot
like the other items:
Notice that it's basically the same - but we're adding an AreaOfEffect component to
indicate that it is what we want. If you were to cargo run now, you'd see Fireball
scrolls in the game - and they would in�ict damage on a single entity. Clearly, we must
�x that!
In our UseItemSystem , we'll build a new section to �gure out a list of targets for an
e�ect:
// Targeting
let mut targets : Vec<Entity> = Vec::new();
match useitem.target {
None => { targets.push( *player_entity ); }
Some(target) => {
let area_effect = aoe.get(useitem.item);
match area_effect {
None => {
// Single target in tile
let idx = map.xy_idx(target.x, target.y);
for mob in map.tile_content[idx].iter() {
targets.push(*mob);
}
}
Some(area_effect) => {
// AoE
let blast_tiles = rltk::field_of_view(target,
area_effect.radius, &*map);
for tile_idx in blast_tiles.iter() {
let idx = map.xy_idx(tile_idx.x, tile_idx.y);
for mob in map.tile_content[idx].iter() {
targets.push(*mob);
}
}
}
}
}
}
This says "if there is no target, apply it to the player". If there is a target, check to see if
it is an Area of E�ect event; if it is - plot a viewshed from that point of the appropriate
radius, and add every entity in the target area. If it isn't, we just get the entities in the
target tile.
So now we need to make the e�ect code generic. We don't want to assume that
e�ects are independent; later on, we may decide that zapping something with a scroll
has all manner of e�ects! So for healing, it looks like this:
The damage code is actually simpli�ed, since we've already calculated targets:
used_item = true;
}
}
}
If you cargo run the project now, you can use magic missile scrolls, �reball scrolls
and health potions.
Confusion Scrolls
Let's add another item - confusion scrolls. These will target a single entity at range,
and make them Confused for a few turns - during which time they will do nothing.
We'll start by describing what we want in the item spawning code:
#[derive(Component, Debug)]
pub struct Confusion {
pub turns : i32
}
That's enough to have them appear, be triggerable and cause targeting to happen -
but nothing will happen when it is used. We'll add the ability to pass along confusion
to the ItemUseSystem :
// Can it pass along confusion? Note the use of scopes to escape from the
borrow checker!
let mut add_confusion = Vec::new();
{
let causes_confusion = confused.get(useitem.item);
match causes_confusion {
None => {}
Some(confusion) => {
used_item = false;
for mob in targets.iter() {
add_confusion.push((*mob, confusion.turns ));
if entity == *player_entity {
let mob_name = names.get(*mob).unwrap();
let item_name = names.get(useitem.item).unwrap();
gamelog.entries.insert(0, format!("You use {} on {},
confusing them.", item_name.name, mob_name.name));
}
}
}
}
}
for mob in add_confusion.iter() {
confused.insert(mob.0, Confusion{ turns: mob.1 }).expect("Unable to
insert status");
}
Alright! Now we can add the Confused status to anything. We should update the
monster_ai_system to use it. Replace the loop with:
if can_act {
let distance =
rltk::DistanceAlg::Pythagoras.distance2d(Point::new(pos.x, pos.y),
*player_pos);
if distance < 1.5 {
wants_to_melee.insert(entity, WantsToMelee{ target:
*player_entity }).expect("Unable to insert attack");
}
else if viewshed.visible_tiles.contains(&*player_pos) {
// Path to the player
let path = rltk::a_star_search(
map.xy_idx(pos.x, pos.y) as i32,
map.xy_idx(player_pos.x, player_pos.y) as i32,
&mut *map
);
if path.success && path.steps.len()>1 {
let mut idx = map.xy_idx(pos.x, pos.y);
map.blocked[idx] = false;
pos.x = path.steps[1] % map.width;
pos.y = path.steps[1] / map.width;
idx = map.xy_idx(pos.x, pos.y);
map.blocked[idx] = true;
viewshed.dirty = true;
}
}
}
}
If this sees a Confused component, it decrements the timer. If the timer hits 0, it
removes it. It then returns, making the monster
Run this chapter's example with web assembly, in your browser (WebGL2 required)
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.
In the last few chapters, we've focused on getting a playable (if not massively fun)
game going. You can run around, slay monsters, and make use of various items.
That's a great start! Most games let you stop playing, and come back later to continue.
Fortunately, Rust (and associated libraries) makes it relatively easy.
A Main Menu
If you're going to resume a game, you need somewhere from which to do so! A main
menu also gives you the option to abandon your last save, possibly view credits, and
generally tell the world that your game is here - and written by you. It's an important
thing to have, so we'll put one together.
Being in the menu is a state - so we'll add it to the ever-expanding RunState enum.
We want to include menu state inside it, so the de�nition winds up looking like this:
Your GUI is probably now telling you that main.rs has errors! It's right - we need to
handle the new RunState option. We'll need to change things around a bit to ensure
that we aren't also rendering the GUI and map when in the menu. So we rearrange
tick :
ctx.cls();
match newrunstate {
RunState::MainMenu{..} => {}
_ => {
draw_map(&self.ecs, ctx);
{
let positions = self.ecs.read_storage::<Position>();
let renderables = self.ecs.read_storage::<Renderable>();
let map = self.ecs.fetch::<Map>();
gui::draw_ui(&self.ecs, ctx);
}
}
}
...
We'll also handle the MainMenu state in our large match for RunState :
RunState::MainMenu{ .. } => {
let result = gui::main_menu(self, ctx);
match result {
gui::MainMenuResult::NoSelection{ selected } => newrunstate =
RunState::MainMenu{ menu_selection: selected },
gui::MainMenuResult::Selected{ selected } => {
match selected {
gui::MainMenuSelection::NewGame => newrunstate =
RunState::PreRun,
gui::MainMenuSelection::LoadGame => newrunstate =
RunState::PreRun,
gui::MainMenuSelection::Quit => { ::std::process::exit(0);
}
}
}
}
}
We're basically updating the state with the new menu selection, and if something has
been selected we change the game state. For Quit , we simply terminate the process.
For now, we'll make loading/starting a game do the same thing: go into the PreRun
state to setup the game.
ctx.print_color_centered(15, RGB::named(rltk::YELLOW),
RGB::named(rltk::BLACK), "Rust Roguelike Tutorial");
if selection == MainMenuSelection::LoadGame {
ctx.print_color_centered(25, RGB::named(rltk::MAGENTA),
RGB::named(rltk::BLACK), "Load Game");
} else {
ctx.print_color_centered(25, RGB::named(rltk::WHITE),
RGB::named(rltk::BLACK), "Load Game");
}
if selection == MainMenuSelection::Quit {
ctx.print_color_centered(26, RGB::named(rltk::MAGENTA),
RGB::named(rltk::BLACK), "Quit");
} else {
ctx.print_color_centered(26, RGB::named(rltk::WHITE),
RGB::named(rltk::BLACK), "Quit");
}
match ctx.key {
None => return MainMenuResult::NoSelection{ selected:
selection },
Some(key) => {
match key {
VirtualKeyCode::Escape => { return
MainMenuResult::NoSelection{ selected: MainMenuSelection::Quit } }
VirtualKeyCode::Up => {
let newselection;
match selection {
MainMenuSelection::NewGame => newselection =
MainMenuSelection::Quit,
MainMenuSelection::LoadGame => newselection =
MainMenuSelection::NewGame,
MainMenuSelection::Quit => newselection =
MainMenuSelection::LoadGame
}
return MainMenuResult::NoSelection{ selected:
newselection }
}
VirtualKeyCode::Down => {
let newselection;
match selection {
MainMenuSelection::NewGame => newselection =
MainMenuSelection::LoadGame,
MainMenuSelection::LoadGame => newselection =
MainMenuSelection::Quit,
MainMenuSelection::Quit => newselection =
MainMenuSelection::NewGame
}
return MainMenuResult::NoSelection{ selected:
newselection }
}
VirtualKeyCode::Return => return
MainMenuResult::Selected{ selected : selection },
_ => return MainMenuResult::NoSelection{ selected:
selection }
}
}
}
}
That's a bit of a mouthful, but it displays menu options and lets you select them with
the up/down keys and enter. It's very careful to not modify state itself, to keep things
clear.
Including Serde
Serde is pretty much the gold-standard for serialization in Rust. It makes a lot of
things easier! So the �rst step is to include it. In your project's Cargo.toml �le, we'll
expand the dependencies section to include it:
[dependencies]
rltk = { git = "https://github.com/thebracket/rltk_rs", features =
["serialization"] }
specs = { version = "0.15.0", features = ["serde"] }
specs-derive = "0.4.0"
serde= { version = "1.0.93", features = ["derive"] }
serde_json = "1.0.39"
It may be worth calling cargo run now - it will take a while, downloading the new
dependencies (and all of their dependencies) and building them for you. It should
keep them around so you don't have to wait this long every time you build.
RunState::SaveGame => {
newrunstate = RunState::MainMenu{ menu_selection :
gui::MainMenuSelection::LoadGame };
}
If you cargo run now, you can start a game and press escape to quit to the menu.
RunState::SaveGame => {
let data = serde_json::to_string(&*self.ecs.fetch::<Map>()).unwrap();
println!("{}", data);
We'll also need to add an extern crate serde; to the top of main.rs .
This won't compile, because we need to tell Map to serialize itself! Fortunately, serde
provides some helpers to make this easy. At the top of map.rs , we add
use serde::{Serialize, Deserialize}; . We then decorate the map to derive
serialization and de-serialization code:
#[serde(skip_serializing)]
#[serde(skip_deserializing)]
pub tile_content : Vec<Vec<Entity>>
}
Lastly, we should extend the game saving code to dump the map to the console:
If you cargo run the project now, when you hit escape it will dump a huge blob of
Introducing Markers
First of all, in main.rs we'll tell Rust that we'd like to make use of the marker
functionality:
Back in main.rs , we'll add SerializeMe to the list of things that we register:
gs.ecs.register::<SimpleMarker<SerializeMe>>();
We'll also add an entry to the ECS resources, which gets used to determine the next
identity:
gs.ecs.insert(SimpleMarkerAllocator::<SerializeMe>::new());
Finally, in spawners.rs we tell each entity builder to include the marker. Here's the
pub fn player(ecs : &mut World, player_x : i32, player_y : i32) -> Entity
{
ecs
.create_entity()
.with(Position { x: player_x, y: player_y })
.with(Renderable {
glyph: rltk::to_cp437('@'),
fg: RGB::named(rltk::YELLOW),
bg: RGB::named(rltk::BLACK),
render_order: 0
})
.with(Player{})
.with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true
})
.with(Name{name: "Player".to_string() })
.with(CombatStats{ max_hp: 30, hp: 30, defense: 2, power: 5 })
.marked::<SimpleMarker<SerializeMe>>()
.build()
}
// InBackpack wrapper
#[derive(Serialize, Deserialize, Clone)]
pub struct InBackpackData<M>(M);
So we start o� by making a "data" class for InBackpack , which simply stores the
entity at which it points. Then we implement convert_info and convert_from to
satisfy Specs' ConvertSaveLoad trait. In convert_into , we use the ids map to get a
saveable ID number for the item, and return an InBackpackData using this marker.
convert_from does the reverse: we get the ID, look up the ID, and return an
InBackpack method.
So that's not too bad. If you look at the source, we've done this for all of the types that
store Entity data - some of which have other data, or multiple Entity types.
RunState::SaveGame => {
saveload_system::save_game(&mut self.ecs);
newrunstate = RunState::MainMenu{ menu_selection :
gui::MainMenuSelection::LoadGame };
}
So... onto implementing save_game . Serde and Specs work decently together, but the
bridge is still pretty roughly de�ned. I kept running into problems like it failing to
compile if I had more than 16 component types! To get around this, I build a macro. I
recommend just copying the macro until you feel ready to learn Rust's (impressive)
macro system.
macro_rules! serialize_individually {
($ecs:expr, $ser:expr, $data:expr, $( $type:ty),*) => {
$(
SerializeComponents::<NoError,
SimpleMarker<SerializeMe>>::serialize(
&( $ecs.read_storage::<$type>(), ),
&$data.0,
&$data.1,
&mut $ser,
)
.unwrap();
)*
};
}
The short version of what it does is that it takes your ECS as the �rst parameter, and a
tuple with your entity store and "markers" stores in it (you'll see this in a moment).
Every parameter after that is a type - listing a type stored in your ECS. These are
repeating rules, so it issues one SerializeComponent::serialize call per type. It's not
as e�cient as doing them all at once, but it works - and doesn't fall over when you
exceed 16 types! The save_game function then looks like this:
// Actually serialize
{
let data = ( ecs.entities(), ecs.read_storage::
<SimpleMarker<SerializeMe>>() );
// Clean up
ecs.delete_entity(savehelper).expect("Crash on cleanup");
}
3. We set data to be a tuple, containing the Entity store and ReadStorage for
SimpleMarker . These will be used by the save macro.
4. We open a File called savegame.json in the current directory.
5. We obtain a JSON serializer from Serde.
6. We call the serialize_individually macro with all of our types.
7. We delete the temporary helper entity we created.
If you cargo run and start a game, then save it - you'll �nd a savegame.json �le has
appeared - with your game state in it. Yay!
Then in gui.rs , we extend the main_menu function to check for the existence of a �le
- and not o�er to load it if it isn't there:
ctx.print_color_centered(15, RGB::named(rltk::YELLOW),
RGB::named(rltk::BLACK), "Rust Roguelike Tutorial");
if save_exists {
if selection == MainMenuSelection::LoadGame {
ctx.print_color_centered(25, RGB::named(rltk::MAGENTA),
RGB::named(rltk::BLACK), "Load Game");
} else {
ctx.print_color_centered(25, RGB::named(rltk::WHITE),
RGB::named(rltk::BLACK), "Load Game");
}
}
if selection == MainMenuSelection::Quit {
ctx.print_color_centered(26, RGB::named(rltk::MAGENTA),
RGB::named(rltk::BLACK), "Quit");
} else {
ctx.print_color_centered(26, RGB::named(rltk::WHITE),
RGB::named(rltk::BLACK), "Quit");
}
match ctx.key {
None => return MainMenuResult::NoSelection{ selected:
selection },
Some(key) => {
match key {
VirtualKeyCode::Escape => { return
MainMenuResult::NoSelection{ selected: MainMenuSelection::Quit } }
VirtualKeyCode::Up => {
let mut newselection;
match selection {
MainMenuSelection::NewGame => newselection =
MainMenuSelection::Quit,
Finally, we'll modify the calling code in main.rs to call game loading:
RunState::MainMenu{ .. } => {
let result = gui::main_menu(self, ctx);
match result {
gui::MainMenuResult::NoSelection{ selected } => newrunstate =
RunState::MainMenu{ menu_selection: selected },
gui::MainMenuResult::Selected{ selected } => {
match selected {
gui::MainMenuSelection::NewGame => newrunstate =
RunState::PreRun,
gui::MainMenuSelection::LoadGame => {
saveload_system::load_game(&mut self.ecs);
newrunstate = RunState::AwaitingInput;
}
gui::MainMenuSelection::Quit => { ::std::process::exit(0);
}
}
}
}
}
macro_rules! deserialize_individually {
($ecs:expr, $de:expr, $data:expr, $( $type:ty),*) => {
$(
DeserializeComponents::<NoError, _>::deserialize(
&mut ( &mut $ecs.write_storage::<$type>(), ),
&mut $data.0, // entities
&mut $data.1, // marker
&mut $data.2, // allocater
&mut $de,
)
.unwrap();
)*
};
}
{
let mut d = (&mut ecs.entities(), &mut ecs.write_storage::
<SimpleMarker<SerializeMe>>(), &mut SimpleMarkerAllocator::
<SerializeMe>::new());
1. Inside a block (to keep the borrow checker happy), we iterate all entities in the
game. We add them to a vector, and then iterate the vector - deleting the
entities. This is a two-step process to avoid invalidating the iterator in the �rst
pass.
2. We open the savegame.json �le, and attach a JSON deserializer.
3. Then we build the tuple for the macro, which requires mutable access to the
entities store, write access to the marker store, and an allocator (from Specs).
4. Now we pass that to the macro we just made, which calls the de-serializer for
each type in turn. Since we saved in the same order, it will pick up everything.
5. Now we go into another block, to avoid borrow con�icts with the previous code
and the entity deletion.
6. We �rst iterate all entities with a SerializationHelper type. If we �nd it, we get
access to the resource storing the map - and replace it. Since we aren't
serializing tile_content , we replace it with an empty set of vectors.
7. Then we �nd the player, by iterating entities with a Player type and a Position
type. We store the world resources for the player entity and his/her position.
8. Finally, we delete the helper entity - so we won't have a duplicate if we save the
game again.
If you cargo run now, you can load your saved game!
pub fn delete_save() {
if Path::new("./savegame.json").exists() {
std::fs::remove_file("./savegame.json").expect("Unable to delete file"); }
}
We'll add a call to main.rs to delete the save after we load the game:
gui::MainMenuSelection::LoadGame => {
saveload_system::load_game(&mut self.ecs);
newrunstate = RunState::AwaitingInput;
saveload_system::delete_save();
}
Web Assembly
The example as-is will compile and run on the web assembly ( wasm32 ) platform: but
as soon as you try to save the game, it crashes. Unfortunately (well, fortunately if you
like your computer not being attacked by every website you go to!), wasm is
sandboxed - and doesn't have the ability to save �les locally.
Rust o�ers conditional compilation (if you are familiar with C, it's a lot like the #define
madness you �nd in big, cross-platform libraries). In saveload_system.rs , we'll
modify save_game to only compile on non-web assembly platforms:
#[cfg(not(target_arch = "wasm32"))]
pub fn save_game(ecs : &mut World) {
That # tag is scary looking, but it makes sense if you unwrap it. #[cfg()] means
"only compile if the current con�guration matches the contents of the parentheses.
not() inverts the result of a check, so when we check that
target_arch = "wasm32") (are we compiling for wasm32 ) the result is inverted. The
end result of this is that the function only compiles if you aren't building for wasm32 .
That's all well and good, but there are calls to that function - so compilation on wasm
will fail. We'll add a stub function to take its place:
#[cfg(target_arch = "wasm32")]
pub fn save_game(_ecs : &mut World) {
}
The #[cfg(target_arch = "wasm32")] pre�x means "only compile this for web
assembly". We've kept the function signature the same, but added a _ before _ecs -
telling the compiler that we intend not to use that variable. Then we keep the function
empty.
The result? You can compile for wasm32 and the save_game function simply doesn't
do anything at all. The rest of the structure remains, so the game correctly returns to
the main menu - but with no resume function.
(Why does the check that the �le exists work? Rust is smart enough to say "no
�lesystem, so the �le can't exist". Thanks, Rust!)
Wrap-up
This has been a long chapter, with quite heavy content. The great news is that we now
have a framework for loading and saving the game whenever we want to. Adding
components has gained some steps: we have to register them in main , tag them for
Serialize, Deserialize , and remember to add them to our component type lists in
saveload_system.rs . That could be easier - but it's a very solid foundation.
Run this chapter's example with web assembly, in your browser (WebGL2 required)
Delving Deeper
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?
#[serde(skip_serializing)]
#[serde(skip_deserializing)]
pub tile_content : Vec<Vec<Entity>>
}
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:
That's it! Our maps now know about depth. You'll want to delete any savegame.json
�les you have lying around, since we've changed the format - loading will fail.
If you cargo run the project now, you'll see that we are showing you your current
depth:
We also want to be able to render the stairs. map.rs contains draw_map , and adding
a tile type is a relatively simple task:
match tile {
TileType::Floor => {
glyph = rltk::to_cp437('.');
fg = RGB::from_f32(0.0, 0.5, 0.5);
}
TileType::Wall => {
glyph = rltk::to_cp437('#');
fg = RGB::from_f32(0., 1.0, 0.);
}
TileType::DownStairs => {
glyph = rltk::to_cp437('>');
fg = RGB::from_f32(0., 1.0, 1.0);
}
}
Lastly, we should place the down stairs. We place the up stairs in the center of the �rst
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:
for _i in 0..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);
let mut 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,1) == 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);
}
}
map
}
If you cargo run the project now, and run around a bit - you can �nd a set of down
stairs! They don't do anything yet, but they are on the map.
// Level changes
VirtualKeyCode::Period => {
if try_next_level(&mut gs.ecs) {
return RunState::NextLevel;
}
}
Your IDE is by now complaining that we haven't actually implemented the new
RunState ! So we go into our ever-growing state handler in main.rs and add:
RunState::NextLevel => {
self.goto_next_level();
newrunstate = RunState::PreRun;
}
We'll add a new impl section for State , so we can attach methods to it. We're �rst
going to create a helper method:
impl State {
fn entities_to_remove_on_level_change(&mut self) -> 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>();
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:
fn goto_next_level(&mut self) {
// Delete entities that aren't the player or his/her equipment
let to_delete = self.entities_to_remove_on_level_change();
for target in to_delete {
self.ecs.delete_entity(target).expect("Unable to delete entity");
}
This is a long function, but does everything we need. Lets break it down step-by-step:
1. We use the helper function we just wrote to obtain a list of entities to delete, and
ask the ECS to dispose of them.
2. We create a worldmap variable, and enter a new scope. Otherwise, we get issues
with immutable vs. mutable borrowing of the ECS.
3. 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).
4. Now we use the same code we used in the initial setup to spawn bad guys and
items in each room.
5. Now we obtain the location of the �rst 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.
6. 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.
7. We give the player a log entry that they have descended to the next level.
8. 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!
Wrapping Up
This chapter was a bit easier than the last couple! You can now descend through an
e�ectively in�nite (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.
Run this chapter's example with web assembly, in your browser (WebGL2 required)
Di�culty
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.
Currently, you can advance through multiple dungeon levels - but they all have the
same spawns. There's no ramp-up of di�culty as you advance, and no easy-mode to
get you through the beginning. This chapter aims to change that.
// Skip Turn
VirtualKeyCode::Numpad5 => return RunState::PlayerTurn,
VirtualKeyCode::Space => return RunState::PlayerTurn,
This adds a nice tactical dimension to the game: you can lure enemies towards you,
and bene�t from tactical placement. Another frequently found feature of roguelikes is
waiting providing some healing if there are no enemies nearby. We'll only implement
that for the player, since mobs suddenly healing up is disconcerting! So we'll change
that to:
// Skip Turn
VirtualKeyCode::Numpad5 => return skip_turn(&mut gs.ecs),
VirtualKeyCode::Space => return skip_turn(&mut gs.ecs),
if can_heal {
let mut health_components = ecs.write_storage::<CombatStats>();
let player_hp =
health_components.get_mut(*player_entity).unwrap();
player_hp.hp = i32::min(player_hp.hp + 1, player_hp.max_hp);
}
RunState::PlayerTurn
}
This looks up various entities, and then iterates the player's viewshed using the
tile_content system. It checks what the player can see for monsters; if no monster
is present, it heals the player by 1 hp. This encourages cerebral play - and can be
balanced with the inclusion of a hunger clock at a later date. It also makes the game
really easy - but we're getting to that!
monsters and items, and then picks each with an equal weight. That's not much like
"normal" games, which tend to make some things rare - and some things common.
We'll create a generic random_table system, for use in the spawn system. Create a
new �le, random_table.rs and put the following in it:
use rltk::RandomNumberGenerator;
impl RandomEntry {
pub fn new<S:ToString>(name: S, weight: i32) -> RandomEntry {
RandomEntry{ name: name.to_string(), weight }
}
}
#[derive(Default)]
pub struct RandomTable {
entries : Vec<RandomEntry>,
total_weight : i32
}
impl RandomTable {
pub fn new() -> RandomTable {
RandomTable{ entries: Vec::new(), total_weight: 0 }
}
roll -= self.entries[index].weight;
index += 1;
}
"None".to_string()
}
}
So this creates a new type, random_table . It adds a new method to it, to facilitate
making a new one. It also creates a vector or entries, each of which has a weight and
a name (passing strings around isn't very e�cient, but makes for clear example
code!). It also implements an add function that lets you pass in a new name and
weight, and updates the structure's total_weight . Finally, roll makes a dice roll
from 0 .. total_weight - 1 , and iterates through entries. If the roll is below the
weight, it returns it - otherwise, it reduces the roll by the weight and tests the next
entry. This gives a chance equal to the relative weight of the entry for any given item
in the table. There's a bit of extra work in there to help chain methods together, for
the Rust-like look of chained function calls. We'll use it in spawner.rs to create a new
function, room_table :
This contains all of the items and monsters we've added so far, with a weight
attached. I wasn't very careful with these weights; we'll play with them later! It does
mean that a call to room_table().roll(rng) will return a random room entry.
#[allow(clippy::map_entry)]
pub fn spawn_room(ecs: &mut World, room : &Rect) {
let spawn_table = room_table();
let mut spawn_points : HashMap<usize, String> = HashMap::new();
for _i in 0 .. num_spawns {
let mut added = false;
let mut tries = 0;
while !added && tries < 20 {
let x = (room.x1 + rng.roll_dice(1, i32::abs(room.x2 -
room.x1))) as usize;
let y = (room.y1 + rng.roll_dice(1, i32::abs(room.y2 -
room.y1))) as usize;
let idx = (y * MAPWIDTH) + x;
if !spawn_points.contains_key(&idx) {
spawn_points.insert(idx, spawn_table.roll(&mut rng));
added = true;
} else {
tries += 1;
}
}
}
}
match spawn.1.as_ref() {
"Goblin" => goblin(ecs, x, y),
"Orc" => orc(ecs, x, y),
"Health Potion" => health_potion(ecs, x, y),
"Fireball Scroll" => fireball_scroll(ecs, x, y),
"Confusion Scroll" => confusion_scroll(ecs, x, y),
"Magic Missile Scroll" => magic_missile_scroll(ecs, x, y),
_ => {}
}
}
}
1. The �rst line tells the Rust linter that we really do like to check a HashMap for
membership and then insert into it - we also set a �ag, which doesn't work well
with its suggestion.
2. We obtain the global random number generator, and set the number of spawns
to be 1d7-3 (for a -2 to 4 range).
3. For each spawn above 0, we pick a random point in the room. We keep picking
random points until we �nd an empty one (or we exceed 20 tries, in which case
we give up). Once we �nd a point, we add it to the spawn list with a location and
a roll from our random table.
4. Then we iterate the spawn list, match on the roll result and spawn monsters and
items.
This is de�nitely cleaner than the previous approach, and now you are less likely to
run into orcs - and more likely to run into goblins and health potions.
Then we'll change the number of entities that spawn to use this:
If you cargo run now, the �rst level is quite quiet. Di�culty ramps up a bit as you
descend, until you have veritable hordes of monsters!
A cargo build later, and voila - you have an increasing probability of �nding orcs,
�reball and confusion scrolls as you descend. The total weight of goblins, health
potions and magic missile scrolls remains the same - but because the others change,
their total likelihood diminishes.
Wrapping Up
You now have a dungeon that increases in di�culty as you descend! In the next
chapter, we'll look at giving your character some progression as well (through
equipment), to balance things out.
Run this chapter's example with web assembly, in your browser (WebGL2 required)
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 dungeon with increasing di�culty, it's time to start giving the
player some ways to improve their performance! In this chapter, we'll start with the
most basic of human tasks: equipping a weapon and shield.
We already have a lot of the item system in place, so we'll build upon the foundation
from previous chapters. Just using components we already have, we can start with the
following in spawners.rs :
In both cases, we're making a new entity. We give it a Position , because it has to
start somewhere on the map. We assign a Renderable , set to appropriate
CP437/ASCII glyphs. We give them a name, and mark them as items. We can add them
to the spawn table like this:
We can also include them in the system that actually spawns them quite readily:
match spawn.1.as_ref() {
"Goblin" => goblin(ecs, x, y),
"Orc" => orc(ecs, x, y),
"Health Potion" => health_potion(ecs, x, y),
"Fireball Scroll" => fireball_scroll(ecs, x, y),
"Confusion Scroll" => confusion_scroll(ecs, x, y),
"Magic Missile Scroll" => magic_missile_scroll(ecs, x, y),
"Dagger" => dagger(ecs, x, y),
"Shield" => shield(ecs, x, y),
_ => {}
}
}
If you cargo run the project now, you can run around and eventually �nd a dagger or
shield. You might consider raising the spawn frequency from 3 to a really big number
while you test! Since we've added the Item tag, you can pick up and drop these items
when you �nd them.
Equippable Component
We need a way to indicate that an item can be equipped. You've probably guessed by
now, but we add a new component! In components.rs , we add:
gs.ecs.register::<Equippable>();
Finally, we should add the Equippable component to our dagger and shield
functions in spawner.rs :
Generally, having a shield in your backpack doesn't help much (obvious "how did you
�t it in there?" questions aside - like many games, we'll gloss over that one!) - so you
have to be able to pick one to equip. We'll start by making another component,
Equipped . This works in a similar fashion to InBackpack - it indicates that an entity is
holding it. Unlike InBackpack , it will indicate what slot is in use. Here's the basic
Equipped component, in components.rs :
Just like before, we need to register it in main.rs , and include it in the serialization
and deserialization lists in saveload_system.rs . Since this includes an Entity , we'll
also have a to include wrapper/helper code to make serialization work. The wrapper is
a lot like others we've written - it converts Equipped into a tuple for save, and back
again for loading:
// Equipped wrapper
#[derive(Serialize, Deserialize, Clone)]
pub struct EquippedData<M>(M, EquipmentSlot);
Now we want to make it possible to actually equip the item. Doing so will
automatically unequip any item in the same slot. We'll do this through the same
interface we already have for using items, so we don't have disparate menus
everywhere. Open inventory_system.rs , and we'll edit ItemUseSystem . We'll start by
expanding the list of systems we are referencing:
This starts by matching to see if we can equip the item. If we can, it looks up the target
slot for the item and looks to see if there is already an item in that slot. If there, it
moves it to the backpack. Lastly, it adds an Equipped component to the item entity
with the owner (the player right now) and the appropriate slot.
Lastly, you may remember that when the player moves to the next level we delete a
lot of entities. We want to include Equipped by the player as a reason to keep an item
in the ECS. In main.rs , we modify entities_to_remove_on_level_change as follows:
let eq = equipped.get(entity);
if let Some(eq) = eq {
if eq.owner == *player_entity {
should_delete = false;
}
}
if should_delete {
to_delete.push(entity);
}
}
to_delete
}
If you cargo run the project now, you can run around picking up the new items - and
you can equip them. They don't do anything, yet - but at least you can swap them in
and out. The game log will show equipping and unequipping.
Logically, a shield should provide some protection against incoming damage - and
being stabbed with a dagger should hurt more than being punched! To facilitate this,
we'll add some more components (this should be a familiar song by now). In
components.rs :
Notice how we've added the component to each? Now we need to modify the
melee_combat_system to apply these bonuses. We do this by adding some additional
ECS queries to our system:
let target_stats =
combat_stats.get(wants_melee.target).unwrap();
if target_stats.hp > 0 {
let target_name =
names.get(wants_melee.target).unwrap();
If you cargo run now, you'll �nd that using your dagger makes you hit harder - and
using your shield makes you su�er less damage.
Now that you can equip items, and remove the by swapping, you may want to stop
holding an item and return it to your backpack. In a game as simple as this one, this
isn't strictly necessary - but it is a good option to have for the future. We'll bind the R
key to remove an item, since that key is available. In player.rs , add this to the input
code:
RunState::ShowRemoveItem => {
let result = gui::remove_item_menu(self, ctx);
match result.0 {
gui::ItemMenuResult::Cancel => newrunstate =
RunState::AwaitingInput,
gui::ItemMenuResult::NoResponse => {}
gui::ItemMenuResult::Selected => {
let item_entity = result.1.unwrap();
let mut intent = self.ecs.write_storage::
<WantsToRemoveItem>();
intent.insert(*self.ecs.fetch::<Entity>(), WantsToRemoveItem{
item: item_entity }).expect("Unable to insert intent");
newrunstate = RunState::PlayerTurn;
}
}
}
We'll implement a new component in components.rs (see the source code for the
serialization handler; it's a cut-and-paste of the handler for wanting to drop an item,
with the names changed):
#[derive(Component, Debug)]
pub struct WantsToRemoveItem {
pub item : Entity
}
Now in gui.rs , we'll implement remove_item_menu . It's almost exactly the same as
the item dropping menu, but changing what is queries and the heading (it'd be a great
idea to make these into more generic functions some time!):
ctx.print(21, y, &name.name.to_string());
equippable.push(entity);
y += 1;
j += 1;
}
match ctx.key {
None => (ItemMenuResult::NoResponse, None),
Some(key) => {
match key {
VirtualKeyCode::Escape => { (ItemMenuResult::Cancel, None)
}
_ => {
let selection = rltk::letter_to_option(key);
if selection > -1 && selection < count as i32 {
return (ItemMenuResult::Selected,
Some(equippable[selection as usize]));
}
(ItemMenuResult::NoResponse, None)
}
}
}
}
}
wants_remove.clear();
}
}
impl State {
fn run_systems(&mut self) {
let mut mapindex = MapIndexingSystem{};
mapindex.run_now(&self.ecs);
let mut vis = VisibilitySystem{};
vis.run_now(&self.ecs);
let mut mob = MonsterAI{};
mob.run_now(&self.ecs);
let mut melee = MeleeCombatSystem{};
melee.run_now(&self.ecs);
let mut damage = DamageSystem{};
damage.run_now(&self.ecs);
let mut pickup = ItemCollectionSystem{};
pickup.run_now(&self.ecs);
let mut itemuse = ItemUseSystem{};
itemuse.run_now(&self.ecs);
let mut drop_items = ItemDropSystem{};
drop_items.run_now(&self.ecs);
let mut item_remove = ItemRemoveSystem{};
item_remove.run_now(&self.ecs);
self.ecs.maintain();
}
}
Now if you cargo run , you can pick up a dagger or shield and equip it. Then you can
press R to remove it.
And back in spawner.rs , we'll add them to the loot table - with a chance of appearing
later in the dungeon:
match spawn.1.as_ref() {
"Goblin" => goblin(ecs, x, y),
"Orc" => orc(ecs, x, y),
"Health Potion" => health_potion(ecs, x, y),
"Fireball Scroll" => fireball_scroll(ecs, x, y),
"Confusion Scroll" => confusion_scroll(ecs, x, y),
"Magic Missile Scroll" => magic_missile_scroll(ecs, x, y),
"Dagger" => dagger(ecs, x, y),
"Shield" => shield(ecs, x, y),
"Longsword" => longsword(ecs, x, y),
"Tower Shield" => tower_shield(ecs, x, y),
_ => {}
}
Now as you descend further, you can �nd better weapons and shields!
match player {
None => {
let victim_name = names.get(entity);
if let Some(victim_name) = victim_name {
log.entries.insert(0, format!("{} is dead",
&victim_name.name));
}
dead.push(entity)
}
Some(_) => {
let mut runstate = ecs.write_resource::<RunState>();
*runstate = RunState::GameOver;
}
}
RunState::GameOver => {
let result = gui::game_over(ctx);
match result {
gui::GameOverResult::NoSelection => {}
gui::GameOverResult::QuitToMenu => {
self.game_over_cleanup();
newrunstate = RunState::MainMenu{ menu_selection:
gui::MainMenuSelection::NewGame };
}
}
}
That's relatively straightforward: we call game_over to render the menu, and when
you quit we delete everything in the ECS. Lastly, in gui.rs we'll implement
game_over :
ctx.print_color_centered(20, RGB::named(rltk::MAGENTA),
RGB::named(rltk::BLACK), "Press any key to return to the menu.");
match ctx.key {
None => GameOverResult::NoSelection,
Some(_) => GameOverResult::QuitToMenu
}
}
fn game_over_cleanup(&mut self) {
// Delete everything
let mut to_delete = Vec::new();
for e in self.ecs.entities().join() {
to_delete.push(e);
}
for del in to_delete.iter() {
self.ecs.delete_entity(*del).expect("Deletion failed");
}
This should look familiar from our serialization work when loading the game. It's very
similar, but it generates a new player.
If you cargo run now, and die - you'll get a message informing you that the game is
done, and sending you back to the menu.
Wrapping Up
That's it for the �rst section of the tutorial. It sticks relatively closely to the Python
tutorial, and takes you from "hello rust" to a moderately fun Roguelike. I hope you've
enjoyed it! Stay tuned, I hope to add a section 2 soon.
Run this chapter's example with web assembly, in your browser (WebGL2 required)
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.
I've been enjoying writing this tutorial, and people are using it (thank you!) - so I
decided to keep adding content. Section 2 is more of a smorgasbord of content than a
structured tutorial. I'll keep adding content as we try to build a great roguelike as a
community.
Please feel free to contact me (I'm @herberticus on Twitter) if you have any
questions, ideas for improvements, or things you'd like me to add. Also, sorry about
all the Patreon spam - hopefully someone will �nd this su�ciently useful to feel like
throwing a co�ee or two my way. :-)
Nicer Walls
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.
So far, we've used a very traditional rendering style for the map. Hash symbols for
walls, periods for �oors. It looks pretty nice, but games like Dwarf Fortress do a lovely
job of using codepage 437's line-drawing characters to make the walls of the dungeon
look smooth. This short chapter will show how to use a bitmask to calculate
appropriate walls, and render them appropriately. As usual, we'll start from the code
from the previous chapter (chapter 1.14).
TileType::Wall => {
glyph = wall_glyph(&*map, x, y);
fg = RGB::from_f32(0., 1.0, 0.);
}
match mask {
0 => { 9 } // Pillar because we can't see neighbors
1 => { 186 } // Wall only to the north
2 => { 186 } // Wall only to the south
3 => { 186 } // Wall to the north and south
4 => { 205 } // Wall only to the west
5 => { 188 } // Wall to the north and west
6 => { 187 } // Wall to the south and west
7 => { 185 } // Wall to the north, south and west
8 => { 205 } // Wall only to the east
9 => { 200 } // Wall to the north and east
10 => { 201 } // Wall to the south and east
11 => { 204 } // Wall to the north, south and east
12 => { 205 } // Wall to the east and west
13 => { 202 } // Wall to the east, west, and south
14 => { 203 } // Wall to the east, west, and north
_ => { 35 } // We missed one?
}
}
1. If we are at the map bounds, we aren't going to risk stepping outside of them -
so we return a # symbol (ASCII 35).
2. Now we create an 8-bit unsigned integer to act as our bitmask . We're interested
in setting individual bits, and only need four of them - so an 8-bit number is
perfect.
3. Next, we check each of the 4 directions and add to the mask. We're adding
numbers corresponding to each of the �rst four bits in binary - so 1,2,4,8. This
means that our �nal number will store whether or not we have each of the four
possible neighbors. For example, a value of 3 means that we have neighbors to
the north and south.
4. Then we match on the resulting mask bit, and return the appropriate line-
drawing character from the codepage 437 character set
This function in turn calls is_revealed_and_wall , so we'll write that too! It's very
simple:
It simply checks to see if a tile is revealed, and if it is a wall - if both are true, it returns
true - otherwise it returns false.
If you cargo run the project now, you get a nicer looking set of walls:
Run this chapter's example with web assembly, in your browser (WebGL2 required)
Bloodstains
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.
Our character lives the life of a "murder-hobo", looting and slaying at will - so it only
makes sense that the pristine dungeon will start to resemble a charnel house. It also
gives us a bridge into a future chapter, in which we'll start to add some particle and
use std::collections::HashSet;
In the map de�nition, we'll include a HashSet of usize (to represent tile indices)
types for blood:
#[serde(skip_serializing)]
#[serde(skip_deserializing)]
pub tile_content : Vec<Vec<Entity>>
}
if map.revealed_tiles[idx] {
let glyph;
let mut fg;
let mut bg = RGB::from_f32(0., 0., 0.);
match tile {
TileType::Floor => {
glyph = rltk::to_cp437('.');
fg = RGB::from_f32(0.0, 0.5, 0.5);
}
TileType::Wall => {
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);
}
}
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
}
ctx.set(x, y, fg, bg, glyph);
}
damage.clear();
}
}
If you cargo run your project, the map starts to show signs of battle!
Run this chapter's example with web assembly, in your browser (WebGL2 required)
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.
There's no real visual feedback for your actions - you hit something, and it either goes
away, or it doesn't. Bloodstains give a good impression of what previously happened in
a location - but it would be nice to give some sort of instant reaction to your actions.
These need to be fast, non-blocking (so you don't have to wait for the animation to
�nish to keep playing), and not too intrusive. Particles are a good �t for this, so we'll
implement a simple ASCII/CP437 particle system.
Particle component
As usual, we'll start out by thinking about what a particle is. Typically it has a position,
something to render, and a lifetime (so it goes away). We've already written two out of
three of those, so lets go ahead and create a ParticleLifetime component. In
components.rs :
We have to register this in all the usual places: main.rs and saveload_system.rs
(twice).
The �rst thing to support is making particles vanish after their lifetime. So we start
with the following in particle_system.rs :
use specs::prelude::*;
use super::{ Rltk, ParticleLifetime};
ctx.cls();
particle_system::cull_dead_particles(&mut self.ecs, ctx);
avoids having to add much intrusive code into each system, and lets us handle the
actual particle spawning as a single (fast) batch.
Our basic ParticleBuilder looks like this. We haven't done anything to actually add
any particles yet, but this provides the requestor service:
struct ParticleRequest {
x: i32,
y: i32,
fg: RGB,
bg: RGB,
glyph: u8,
lifetime: f32
}
impl ParticleBuilder {
pub fn new() -> ParticleBuilder {
ParticleBuilder{ requests : Vec::new() }
}
pub fn request(&mut self, x:i32, y:i32, fg: RGB, bg:RGB, glyph: u8,
lifetime: f32) {
self.requests.push(
ParticleRequest{
x, y, fg, bg, glyph, lifetime
}
);
}
}
gs.ecs.insert(particle_system::ParticleBuilder::new());
particle_builder.requests.clear();
}
}
This is a very simple service: it iterates the requests, and creates an entity for each
particle with the component parameters from the request. Then it clears the builder
list. The last step is to add it to the system schedule in main.rs :
We've made it depend upon likely particle spawners. We'll have to be a little careful to
avoid accidentally making it concurrent with anything that might add to it.
And the expanded list of resources for the run method itself:
If you cargo run now, you'll see a relatively subtle particle feedback to show that
melee combat occurred. This de�nitely helps with the feel of gameplay, and is
su�ciently non-intrusive that we aren't making our other systems too confusing.
#[allow(clippy::cognitive_complexity)]
fn run(&mut self, data : Self::SystemData) {
let (player_entity, mut gamelog, map, entities, mut wants_use,
names,
consumables, healing, inflict_damage, mut combat_stats, mut
suffer_damage,
aoe, mut confused, equippable, mut equipped, mut backpack, mut
particle_builder, positions) = data;
We'll start by showing a heart when you drink a healing potion. In the healing section:
We can use a similar e�ect for confusion - only with a magenta question mark. In the
confusion section:
We should also use a particle to indicate that damage was in�icted. In the damage
section of the system:
Lastly, if an e�ect hits a whole area (for example, a �reball) it would be good to
indicate what the area is. In the targeting section of the system, add:
That wasn't too hard, was it? If you cargo run your project now, you'll see various
visual e�ects �ring.
can_act = false;
We don't need to worry about getting the Position component here, because we
already get it as part of the loop. If you cargo run your project now, and �nd a
confusion scroll - you have visual feedback as to why a goblin isn't chasing you
anymore:
Wrap Up
That's it for visual e�ects for now. We've given the game a much more visceral feel,
with feedback given for actions. That's a big improvement, and goes a long way to
modernizing an ASCII interface!
Run this chapter's example with web assembly, in your browser (WebGL2 required)
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.
Hunger clocks are a controversial feature of a lot of roguelikes. They can really irritate
the player if you are spending all of your time looking for food, but they also drive you
forward - so you can't sit around without exploring more. Resting to heal becomes
more of a risk/reward system, in particular. This chapter will implement a basic
hunger clock for the player.
pub fn player(ecs : &mut World, player_x : i32, player_y : i32) -> Entity
{
ecs
.create_entity()
.with(Position { x: player_x, y: player_y })
.with(Renderable {
glyph: rltk::to_cp437('@'),
fg: RGB::named(rltk::YELLOW),
bg: RGB::named(rltk::BLACK),
render_order: 0
})
.with(Player{})
.with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true
})
.with(Name{name: "Player".to_string() })
.with(CombatStats{ max_hp: 30, hp: 30, defense: 2, power: 5 })
.with(HungerClock{ state: HungerState::WellFed, duration: 20 })
.marked::<SimpleMarker<SerializeMe>>()
.build()
}
use specs::prelude::*;
use super::{HungerClock, RunState, HungerState, SufferDamage,
gamelog::GameLog};
match *runstate {
RunState::PlayerTurn => {
if entity == *player_entity {
proceed = true;
}
}
RunState::MonsterTurn => {
if entity != *player_entity {
proceed = true;
}
}
_ => proceed = false
}
if proceed {
clock.duration -= 1;
if clock.duration < 1 {
match clock.state {
HungerState::WellFed => {
clock.state = HungerState::Normal;
clock.duration = 200;
if entity == *player_entity {
log.entries.insert(0, "You are no longer
well fed.".to_string());
}
}
HungerState::Normal => {
clock.state = HungerState::Hungry;
clock.duration = 200;
if entity == *player_entity {
log.entries.insert(0, "You are
hungry.".to_string());
}
}
HungerState::Hungry => {
clock.state = HungerState::Starving;
clock.duration = 200;
if entity == *player_entity {
log.entries.insert(0, "You are
starving!".to_string());
}
}
HungerState::Starving => {
// Inflict damage from hunger
if entity == *player_entity {
log.entries.insert(0, "Your hunger pangs
are getting painful! You suffer 1 hp damage.".to_string());
}
inflict_damage.insert(entity, SufferDamage{
amount: 1 }).expect("Unable to do damage");
}
}
}
}
}
}
}
It works by iterating all entities that have a HungerClock . If they are the player, it only
takes e�ect in the PlayerTurn state; likewise, if they are a monster, it only takes place
in their turn (in case we want hungry monsters later!). The duration of the current
state is reduced on each run-through. If it hits 0, it moves one state down - or if you
are starving, damages you.
impl State {
fn run_systems(&mut self) {
let mut mapindex = MapIndexingSystem{};
mapindex.run_now(&self.ecs);
let mut vis = VisibilitySystem{};
vis.run_now(&self.ecs);
let mut mob = MonsterAI{};
mob.run_now(&self.ecs);
let mut melee = MeleeCombatSystem{};
melee.run_now(&self.ecs);
let mut damage = DamageSystem{};
damage.run_now(&self.ecs);
let mut pickup = ItemCollectionSystem{};
pickup.run_now(&self.ecs);
let mut itemuse = ItemUseSystem{};
itemuse.run_now(&self.ecs);
let mut drop_items = ItemDropSystem{};
drop_items.run_now(&self.ecs);
let mut item_remove = ItemRemoveSystem{};
item_remove.run_now(&self.ecs);
let mut hunger = hunger_system::HungerSystem{};
hunger.run_now(&self.ecs);
let mut particles = particle_system::ParticleSpawnSystem{};
particles.run_now(&self.ecs);
self.ecs.maintain();
}
}
If you cargo run now, and hit wait a lot - you'll starve to death.
match hc.state {
HungerState::WellFed => ctx.print_color(71, 42,
RGB::named(rltk::GREEN), RGB::named(rltk::BLACK), "Well Fed"),
HungerState::Normal => {}
HungerState::Hungry => ctx.print_color(71, 42,
RGB::named(rltk::ORANGE), RGB::named(rltk::BLACK), "Hungry"),
HungerState::Starving => ctx.print_color(71, 42,
RGB::named(rltk::RED), RGB::named(rltk::BLACK), "Starving"),
}
}
...
If you cargo run your project, this gives quite a pleasant display:
Adding in food
It's all well and good starving to death, but players will �nd it frustrating if they always
start do die after 620 turns (and su�er consequences before that! 620 may sound like
a lot, but it's common to use a few hundred moves on a level, and we aren't trying to
make food the primary game focus). We'll introduce a new item, Rations . We have
most of the components needed for this already, but we need a new one to indicate
match spawn.1.as_ref() {
"Goblin" => goblin(ecs, x, y),
"Orc" => orc(ecs, x, y),
"Health Potion" => health_potion(ecs, x, y),
"Fireball Scroll" => fireball_scroll(ecs, x, y),
"Confusion Scroll" => confusion_scroll(ecs, x, y),
"Magic Missile Scroll" => magic_missile_scroll(ecs, x, y),
"Dagger" => dagger(ecs, x, y),
"Shield" => shield(ecs, x, y),
"Longsword" => longsword(ecs, x, y),
"Tower Shield" => tower_shield(ecs, x, y),
"Rations" => rations(ecs, x, y),
_ => {}
}
If you cargo run now, you will encounter rations that you can pickup and drop. You
can't, however, eat them! We'll add that to inventory_system.rs . Here's the relevant
portion (see the tutorial source for the full version):
If you cargo run now, you can run around - �nd rations, and eat them to reset the
hunger clock!
let hc = hunger_clock.get(entity);
if let Some(hc) = hc {
if hc.state == HungerState::WellFed {
offensive_bonus += 1;
}
}
And that's it! You get a +1 power bonus for being full of rations.
if can_heal {
Wrap-Up
We now have a working hunger clock system. You may want to tweak the durations to
suit your taste (or skip it completely if it isn't your cup of tea) - but it's a mainstay of
the genre, so it's good to have it included in the tutorials.
Run this chapter's example with web assembly, in your browser (WebGL2 required)
Magic Mapping
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.
A really common item in roguelikes is the scroll of magic mapping. You read it, and the
dungeon is revealed. Fancier roguelikes have nice graphics for it. In this chapter, we'll
start by making it work - and then make it pretty!
over to spawners.rs and create a new function for it, as well as adding it to the loot
tables:
Notice that we've given it a weight of 400 - absolutely ridiculous. We'll �x it later, for
now we really want to spawn the scroll so that we can test it! Lastly, we add it to the
actual spawn function:
match spawn.1.as_ref() {
"Goblin" => goblin(ecs, x, y),
"Orc" => orc(ecs, x, y),
"Health Potion" => health_potion(ecs, x, y),
"Fireball Scroll" => fireball_scroll(ecs, x, y),
"Confusion Scroll" => confusion_scroll(ecs, x, y),
"Magic Missile Scroll" => magic_missile_scroll(ecs, x, y),
"Dagger" => dagger(ecs, x, y),
"Shield" => shield(ecs, x, y),
"Longsword" => longsword(ecs, x, y),
"Tower Shield" => tower_shield(ecs, x, y),
"Rations" => rations(ecs, x, y),
"Magic Mapping Scroll" => magic_mapping_scroll(ecs, x, y),
_ => {}
}
If you were to cargo run now, you'd likely �nd scrolls you can pick up - but they
won't do anything.
There are some framework changes also (see the source); we've done this often
enough, I don't think it needs repeating here again. If you cargo run the project now,
�nd a scroll (they are everywhere) and use it - the map is instantly revealed:
Making it pretty
While the code presented there is e�ective, it isn't visually attractive. It's nice to
include �u� in games, and let the user be pleasantly surprised by the beauty of an
ASCII terminal from time to time! We'll start by modifying inventory_system.rs
again:
Notice that instead of modifying the map, we are just changing the game state to
mapping mode. We don't actually support doing that yet, so lets go into the state
mapper in main.rs and modify PlayerTurn to handle it:
RunState::PlayerTurn => {
self.systems.dispatch(&self.ecs);
self.ecs.maintain();
match *self.ecs.fetch::<RunState>() {
RunState::MagicMapReveal{ .. } => newrunstate =
RunState::MagicMapReveal{ row: 0 },
_ => newrunstate = RunState::MonsterTurn
}
}
We also add some logic to the tick loop for the new state:
RunState::MagicMapReveal{row} => {
let mut map = self.ecs.fetch_mut::<Map>();
for x in 0..MAPWIDTH {
let idx = map.xy_idx(x as i32,row);
map.revealed_tiles[idx] = true;
}
if row as usize == MAPHEIGHT-1 {
newrunstate = RunState::MonsterTurn;
} else {
newrunstate = RunState::MagicMapReveal{ row: row+1 };
}
}
This is pretty straightforward: it reveals the tiles on the current row, and then if we
haven't hit the bottom of the map - it adds to row. If we have, it returns to where we
were - MonsterTurn . If you cargo run now, �nd a magic mapping scroll and use it,
the map fades in nicely:
Wrap Up
This was a relatively quick chapter, but we now have another staple of the roguelike
genre: magic mapping.
Run this chapter's example with web assembly, in your browser (WebGL2 required)
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.
Our main menu is really boring, and not a good way to attract players! This chapter
will spice it up a bit.
REX Paint
Grid Sage Games (the amazing u/Kyzrati on Reddit) provide a lovely tool for Codepage
437 image editing called REX Paint. RLTK has built-in support for using the output
from this editor. As they used to say on the BBC's old kids show Blue Peter - here's one
I made earlier.
I cheated a bit; I found a CC0 image, resized it to 80x50 in the GIMP, and used a tool I
wrote years ago to convert the PNG to a REX Paint �le. Still, I like the result. You can
�nd the REX Paint �le in the resources folder.
We'll introduce a new �le, rex_assets.rs to store our REX sprites. The �le looks like
this:
use rltk::{rex::XpFile};
rltk::embedded_resource!(SMALL_DUNGEON, "../../resources
/SmallDungeon_80x50.xp");
impl RexAssets {
#[allow(clippy::new_without_default)]
pub fn new() -> RexAssets {
rltk::link_resource!(SMALL_DUNGEON, "../../resources
/SmallDungeon_80x50.xp");
RexAssets{
menu : XpFile::from_resource("../../resources
/SmallDungeon_80x50.xp").unwrap()
}
}
}
Very simple - it de�nes a structure, and loads the dungeon graphic into it when new is
called. We'll also insert it into Specs as a resource so we can access our sprites
anywhere. There are some new concepts here:
In main.rs :
gs.ecs.insert(rex_assets::RexAssets::new());
Now we open up gui.rs and �nd the main_menu function. We'll add two lines before
we start printing menu content:
if save_exists {
if selection == MainMenuSelection::LoadGame {
ctx.print_color_centered(y, RGB::named(rltk::MAGENTA),
RGB::named(rltk::BLACK), "Load Game");
} else {
ctx.print_color_centered(y, RGB::named(rltk::WHITE),
RGB::named(rltk::BLACK), "Load Game");
}
y += 1;
}
if selection == MainMenuSelection::Quit {
ctx.print_color_centered(y, RGB::named(rltk::MAGENTA),
RGB::named(rltk::BLACK), "Quit");
} else {
ctx.print_color_centered(y, RGB::named(rltk::WHITE),
RGB::named(rltk::BLACK), "Quit");
}
...
Simple Traps
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.
Most roguelikes, like their D&D precursors, feature traps in the dungeon. Walk down
an innocent looking hallway, and oops - an arrow �ies out and hits you. This chapter
will implement some simple traps, and then examine some of the game implications
they bring.
What is a trap?
Most traps follow the pattern of: you might see the trap (or you might not!), you enter
the tile anyway, the trap goes o� and something happens (damage, teleport, etc.). So
traps can be logically divided into three sections:
Let's work our way through getting components into place for these, in turn.
We'll also add it into the list of things that can spawn:
match spawn.1.as_ref() {
"Goblin" => goblin(ecs, x, y),
"Orc" => orc(ecs, x, y),
"Health Potion" => health_potion(ecs, x, y),
"Fireball Scroll" => fireball_scroll(ecs, x, y),
"Confusion Scroll" => confusion_scroll(ecs, x, y),
"Magic Missile Scroll" => magic_missile_scroll(ecs, x, y),
"Dagger" => dagger(ecs, x, y),
"Shield" => shield(ecs, x, y),
"Longsword" => longsword(ecs, x, y),
"Tower Shield" => tower_shield(ecs, x, y),
"Rations" => rations(ecs, x, y),
"Magic Mapping Scroll" => magic_mapping_scroll(ecs, x, y),
"Bear Trap" => bear_trap(ecs, x, y),
_ => {}
}
If you cargo run the project now, occasionally you will run into a red ^ - and it will
be labeled "Bear Trap" on the mouse-over. Not massively exciting, but a good start!
Note that for testing, we'll up the spawn frequency from 2 to 100 - LOTS of traps,
making debugging easier. Remember to lower it later!
Now, we want to modify the object renderer to not show things that are hidden. The
Specs Book provides a great clue as to how to exclude a component from a join, so we
do that (in main.rs ):
Notice that we've added a ! ("not" symbol) to the join - we're saying that entities
must not have the Hidden component if we are to render them.
If you cargo run the project now, the bear traps are no longer visible. However, they
show up in tool tips (which may be perhaps as well, we know they are there!). We'll
exclude them from tool-tips also. In gui.rs , we amend the draw_tooltips function:
Now if you cargo run , you'll have no idea that traps are present. Since they don't do
anything yet - they may as well not exist!
We also need to have traps �re their trigger when an entity enters them. We'll add
another component, EntityMoved to indicate that an entity has moved this turn. In
components.rs (and remembering to register in main.rs and saveload_system.rs ):
Now, we scour the codebase to add an EntityMoved component every time an entity
moves. In player.rs , we handle player movement in the try_move_player function.
At the top, we'll gain write access to the relevant component store:
Then when we've determined that the player did, in fact, move - we'll insert the
EntityMoved component:
for the AI is getting a bit long, so I recommend you look at the source �le directly for
this one (here).
Lastly, we need a system to make triggers actually do something. We'll make a new �le,
trigger_system.rs :
}
}
We also have to go into main.rs and insert code to run the system. It goes after the
Monster AI, since monsters can move - but we might output damage, so that system
needs to run later:
...
let mut mob = MonsterAI{};
mob.run_now(&self.ecs);
let mut triggers = trigger_system::TriggerSystem{};
triggers.run_now(&self.ecs);
...
If you cargo run now, you can move around - and walking into a trap will damage
you. If a monster walks into a trap, it damages them too! It even plays the particle
e�ect for attacking.
.with(SingleActivation{})
Now we modify the trigger_system to apply it. Note that we remove the entities
after looping through them, to avoid confusing our iterators.
log.entries.insert(0, format!("{}
triggers!", &name.name));
}
If you cargo run now (I recommend cargo run --release - it's getting slower!), you
can be hit by a bear trap - take some damage, and the trap goes away.
Spotting Traps
We have a pretty functional trap system now, but it's annoying to randomly take
damage for no apparent reason - because you had no way to know that a trap was
there. It's also quite unfair, since there's no way to guard against it. We'll implement a
chance to spot traps. At some point in the future, this might be tied to an attribute or
skill - but for now, we'll go with a dice roll. That's a bit nicer than asking everyone to
carry a 10 foot pole with them at all times (like some early D&D games!).
Since the visibility_system already handles revealing tiles, why not make it
potentially reveal hidden things, too? Here's the code for visibility_system.rs :
So why a 1 in 24 chance to spot traps? I played around until it felt about right. 1 in 6
(my �rst choice) was too good. Since your viewshed updates whenever you move, you
have a high chance of spotting traps as you move around. Like a lot of things in game
design: sometimes you just have to play with it until it feels right!
If you cargo run now, you can walk around - and sometimes spot traps. Monsters
won't reveal traps, unless they fall into them.
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.
This started out as part of section 2, but I realized it was a large, open topic. The larger
roguelike games, such as Dungeon Crawl Stone Soup, Cogmind, Caves of Qud, etc. all
have a variety of maps. Section 3 is all about map building, and will cover many of the
available algorithms for procedurally building interesting maps.
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.
So far, we've really just had one map design. It's di�erent every time (unless you hit a
repeat random seed), which is a great start - but the world of procedural generation
leaves so many more possibilities. Over the next few chapters, we'll start building a
few di�erent map types.
To better organize our code, we'll make a module. Rust lets you make a directory, with
a �le in it called mod.rs - and that directory is now a module. Modules are exposed
through mod and pub mod , and provide a way to keep parts of your code together.
The mod.rs �le provides an interface - that is, a list of what is provided by the module,
and how to interact with it. Other �les in the module can do whatever they want,
safely isolated from the rest of the code.
So, we'll create a directory (o� of src ) called map_builders . In that directory, we'll
create an empty �le called mod.rs . We're trying to de�ne an interface, so we'll start
with a skeleton. In mod.rs :
use super::Map;
trait MapBuilder {
fn build(new_depth: i32) -> Map;
}
The use of trait is new! A trait is like an interface in other languages: you are saying
that any other type can implement the trait, and can then be treated as a variable of
that type. Rust by Example has a great section on traits, as does The Rust Book. What
we're stating is that anything can declare itself to be a MapBuilder - and that includes
a promise that they will provide a build function that takes in an ECS World object,
and returns a map.
Open up map.rs , and add a new function - called, appropriately enough, new :
We'll need this for other map generators, and it makes sense for a Map to know how
to return a new one as a constructor - without having to encapsulate all the logic for
map layout. The idea is that any Map will work basically the same way, irrespective of
how we've decided to populate it.
Now we'll create a new �le, also inside the map_builders directory. We'll call it
simple_map.rs - and it'll be where we put the existing map generation system. We'll
also put a skeleton in place here:
use super::MapBuilder;
use super::Map;
use specs::prelude::*;
This simply returns an unusable, solid map. We'll �esh out the details in a bit - lets get
the interface working, �rst.
Now, back in map_builders/mod.rs we add a public function. For now, it just calls the
builder in SimpleMapBuilder :
Ok, so that was a fair amount of work to not actually do anything - but we've gained a
clean interface o�ering map creation (via a single function), and setup a trait to
require that our map builders work in a similar fashion. That's a good start.
These are exactly the same as the functions from map.rs , but with map passed as a
mutable reference (so you are working on the original, rather than a new one) and all
vestiges of self gone. These are free functions - that is, they are functions available
from anywhere, not tied to a type. The pub fn means they are public within the
module - unless we add pub use to the module itself, they aren't passed out of the
module to the main program. This helps keeps code organized.
Now that we have these helpers, we can start porting the map builder itself. In
simple_map.rs , we start by �eshing out the build function a bit:
impl SimpleMapBuilder {
fn rooms_and_corridors(map : &mut Map) {
const MAX_ROOMS : i32 = 30;
const MIN_SIZE : i32 = 6;
const MAX_SIZE : i32 = 10;
for _i in 0..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);
let mut ok = true;
for other_room in map.rooms.iter() {
if new_room.intersect(other_room) { ok = false }
}
if ok {
apply_room_to_map(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,1) == 1 {
apply_horizontal_tunnel(map, prev_x, new_x,
prev_y);
apply_vertical_tunnel(map, prev_y, new_y, new_x);
} else {
apply_vertical_tunnel(map, prev_y, new_y, prev_x);
apply_horizontal_tunnel(map, prev_x, new_x,
new_y);
}
}
map.rooms.push(new_room);
}
}
This is only the �rst half of generation, but it's a good start! Now go to map.rs , and
delete the entire new_map_rooms_and_corridors function. Also delete the ones we
replicated in common.rs . The map.rs �le looks much cleaner now, without any
references to map building strategy! Of course, your compiler/IDE is probably telling
you that we've broken a bunch of stu�. That's ok - and a normal part of "refactoring" -
the process of changing code to be easier to work with.
There are three lines in main.rs that are now �agged by the compiler.
We can replace
*worldmap_resource = Map::new_map_rooms_and_corridors(current_depth +
1);
with
*worldmap_resource = map_builders::build_random_map(current_depth +
1);
.
*worldmap_resource = Map::new_map_rooms_and_corridors(1); can become
*worldmap_resource = map_builders::build_random_map(1); .
let map : Map = Map::new_map_rooms_and_corridors(1); transforms to
let map : Map = map_builders::build_random_map(1); .
If you cargo run now, you'll notice: the game is exactly the same! That's good: we've
successfully refactored functionality out of Map and into map_builders .
trait MapBuilder {
fn build(new_depth: i32) -> (Map, Position);
}
Notice that we're using a tuple to return two values at once. We've talked about those
earlier, but this is a great example of why they are useful! We now need to go into
simple_map to make the build function actually return the correct data. The
de�nition of build in simple_map.rs now looks like this:
This has, of course, broken the code we updated in main.rs . We can quickly take care
of that! The �rst error can be taken care of with the following code:
Notice how we use destructuring to retrieve both the map and the start position from
the builder. We then put these in the appropriate places. Since assignment in Rust is a
move operation, this is pretty e�cient - and the compiler can get rid of temporary
assignments for us.
We do the same again on the second error (around line 369). It's almost exactly the
same code, so feel free to check the source code for this chapter if you are stuck.
Alright, lets cargo run that puppy! If all went well, then... nothing has changed. We've
made a signi�cant gain, however: our map building strategy now determines the
player's starting point on a level, not the map itself.
trait MapBuilder {
fn build(new_depth: i32) -> (Map, Position);
fn spawn(map : &Map, ecs : &mut World, new_depth: i32);
}
Simple enough: it requires the ECS (since we're adding entities) and the map. We'll
also add a public function, spawn to provide an external interface to layout out the
monsters:
Now we open simple_map.rs and actually implement spawn . Fortunately, it's very
simple:
Now, we can go into main.rs and �nd every time we loop through calling
spawn_room and replace it with a call to map_builders::spawn .
Once again, cargo run should give you the same game we've been looking at for 22
chapters!
If you look closely at what we have so far, there's one problem: the builder has no way
of knowing what should be used for the second call to the builder (spawning things).
That's because our functions are stateless - we don't actually create a builder and give
it a way to remember anything. Since we want to support a wide variety of builders,
we should correct that.
This introduces a new Rust concept: dynamic dispatch. The Rust Book has a good
section on this if you are familiar with the concept. If you've previously used an Object
Oriented Programming language, then you will have encountered this also. The basic
idea is that you have a "base object" that speci�es an interface - and multiple objects
implement the functions from the interface. You can then, at run-time (when the
program runs, rather than when it compiles) put any object that implements the
interface into a variable typed by the interface - and when you call the methods from
the interface, the implementation runs from the actual type. This is nice because your
underlying program doesn't have to know about the actual implementations - just
how to talk to the interface. That helps keep your program clean.
Dynamic dispatch does come with a cost, which is why Entity Component Systems
(and Rust in general) prefer not to use it for performance-critical code. There's actually
two costs:
1. Since you don't know what type the object is up-front, you have to allocate it via
a pointer. Rust makes this easy by providing the Box system (more on that in a
moment), but there is a cost: rather than just jumping to a readily de�ned piece
of memory (which your CPU/memory can generally �gure out easily in advance
and make sure the cache is ready) the code has to follow the pointer - and then
run what it �nds at the end of the pointer. That's why some C++ programmers
call -> (dereference operator) the "cache miss operator". Simply by being
boxed, your code is slowed down by a tiny amount.
2. Since multiple types can implement methods, the computer needs to know
which one to run. It does this with a vtable - that is, a "virtual table" of method
implementations. So each call has to check the table, �nd out which method to
run, and then run from there. That's another cache miss, and more time for your
CPU to �gure out what to do.
In this case, we're just generating the map - and making very few calls into the builder.
That makes the slowdown acceptable, since it's really small and not being run
frequently. You wouldn't want to do this in your main loop, if you can avoid it!
So - implementation. We'll start by changing our trait to be public, and have the
methods accept an &mut self - which means "this method is a member of the trait,
and should receive access to self - the attached object when we call it. The code
looks like this:
Notice that I've also taken the time to make the names a bit more descriptive! Now we
replace our free function calls with a factory function: it creates a MapBuilder and
returns it. The name is a bit of a lie until we have more map implementations - it
claims to be random, but when there's only one choice it's not hard to guess which
one it will pick (just ask Soviet election systems!):
Over in main.rs , we once again have to change all three calls to the map builder. We
now need to use the following pattern:
It's not very di�erent, but now we're keeping the builder object around - so
subsequent calls to the builder will apply to the same implementation (sometimes
called "concrete object" - the object that actually physically exists).
If we were to add 5 more map builders, the code in main.rs wouldn't care! We can
add them to the factory, and the rest of the program is blissfully unaware of the
workings of the map builder. This is a very good example of how dynamic dispatch
can be useful: you have a clearly de�ned interface, and the rest of the program
doesn't need to understand the inner workings.
impl SimpleMapBuilder {
pub fn new(new_depth : i32) -> SimpleMapBuilder {
SimpleMapBuilder{}
}
...
That simply returns an empty object for now. In mod.rs , change the
random_map_builder function to use it:
This hasn't gained us anything, but is a bit cleaner - when you write more maps, they
may do something in their constructors!
map state.
2. spawn_entities no longer asks for a Map parameter. Since all map builders
have to implement a map in order to make sense, we're going to assume that
the map builder has one.
3. get_map returns a map. Again, we're assuming that the builder implementation
keeps one.
4. get_starting_position also assumes that the builder will keep one around.
Obviously, our SimpleMapBuilder now needs to be modi�ed to work this way. We'll
start by modifying the struct to include the required variables. This is the map
builder's state - and since we're doing dynamic object-oriented code, the state
remains attached to the object. Here's the code from simple_map.rs :
Next, we'll implement the getter functions. These are very simple: they simply return
the variables from the structure's state:
fn build_map(&mut self) {
SimpleMapBuilder::rooms_and_corridors();
}
fn rooms_and_corridors(&mut self) {
const MAX_ROOMS : i32 = 30;
const MIN_SIZE : i32 = 6;
const MAX_SIZE : i32 = 10;
for _i in 0..MAX_ROOMS {
let w = rng.range(MIN_SIZE, MAX_SIZE);
let h = rng.range(MIN_SIZE, MAX_SIZE);
let x = rng.roll_dice(1, self.map.width - w - 1) - 1;
let y = rng.roll_dice(1, self.map.height - h - 1) - 1;
let new_room = Rect::new(x, y, w, h);
let mut ok = true;
for other_room in self.map.rooms.iter() {
if new_room.intersect(other_room) { ok = false }
}
if ok {
apply_room_to_map(&mut self.map, &new_room);
if !self.map.rooms.is_empty() {
let (new_x, new_y) = new_room.center();
let (prev_x, prev_y) =
self.map.rooms[self.map.rooms.len()-1].center();
if rng.range(0,1) == 1 {
apply_horizontal_tunnel(&mut self.map, prev_x, new_x,
prev_y);
apply_vertical_tunnel(&mut self.map, prev_y, new_y,
new_x);
} else {
apply_vertical_tunnel(&mut self.map, prev_y, new_y,
prev_x);
apply_horizontal_tunnel(&mut self.map, prev_x, new_x,
new_y);
}
}
self.map.rooms.push(new_room);
}
}
This is very similar to what we had before, but now uses self.map to refer to its own
copy of the map, and stores the player position in self.starting_position .
The calls into the new code in main.rs once again change. The call from
goto_next_level now looks like this:
We basically repeat those changes for the others (see the source). We now have a
pretty comfortable interface into the map builder: it exposes enough to be easy to
use, without exposing the details of the magic it uses to actually build the map!
If you cargo run the project now: once again, nothing visible has changed - it still
works the way it did before. When you are refactoring, that's a good thing!
least not in the "here's a rectangle, we're calling a room" sense. Lets try and move that
abstraction out of the map, and also out of the spawner.
#[serde(skip_serializing)]
#[serde(skip_deserializing)]
pub tile_content : Vec<Vec<Entity>>
}
We also remove it from the new function. Take a look at your IDE, and you'll notice
that you've only broken code in simple_map.rs ! We weren't using the rooms
anywhere else - which is a pretty big clue that they don't belong in the map we're
passing around throughout the main program.
We can �x simple_map by putting rooms into the builder rather than the map. We'll
put it into the structure:
fn rooms_and_corridors(&mut self) {
const MAX_ROOMS : i32 = 30;
const MIN_SIZE : i32 = 6;
const MAX_SIZE : i32 = 10;
for _i in 0..MAX_ROOMS {
let w = rng.range(MIN_SIZE, MAX_SIZE);
let h = rng.range(MIN_SIZE, MAX_SIZE);
let x = rng.roll_dice(1, self.map.width - w - 1) - 1;
let y = rng.roll_dice(1, self.map.height - h - 1) - 1;
let new_room = Rect::new(x, y, w, h);
let mut ok = true;
for other_room in self.rooms.iter() {
if new_room.intersect(other_room) { ok = false }
}
if ok {
apply_room_to_map(&mut self.map, &new_room);
if !self.rooms.is_empty() {
let (new_x, new_y) = new_room.center();
let (prev_x, prev_y) = self.rooms[self.rooms.len()-
1].center();
if rng.range(0,1) == 1 {
apply_horizontal_tunnel(&mut self.map, prev_x, new_x,
prev_y);
apply_vertical_tunnel(&mut self.map, prev_y, new_y,
new_x);
} else {
apply_vertical_tunnel(&mut self.map, prev_y, new_y,
prev_x);
apply_horizontal_tunnel(&mut self.map, prev_x, new_x,
new_y);
}
}
self.rooms.push(new_room);
}
}
Once again, cargo run the project: and nothing should have changed.
Wrap-up
This was an interesting chapter to write, because the objective is to �nish with code
that operates exactly as it did before - but with the map builder cleaned into its own
module, completely isolated from the rest of the code. That gives us a great starting
point to start building new map builders, without having to change the game itself.
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.
As we're diving into generating new and interesting maps, it would be helpful to
provide a way to see what the algorithms are doing. This chapter will build a test
harness to accomplish this, and extend the SimpleMapBuilder from the previous
chapter to support it. This is going to be a relatively large task, and we'll learn some
new techniques along the way!
We'll start by changing the �rst one to insert placeholder values rather than the actual
values we intend to use. This way, the World has the slots for the data - it just isn't all
that useful yet. Here's a version with the old code commented out:
gs.ecs.insert(SimpleMarkerAllocator::<SerializeMe>::new());
gs.ecs.insert(Map::new(1));
gs.ecs.insert(Point::new(0, 0));
gs.ecs.insert(rltk::RandomNumberGenerator::new());
So instead of building the map, we put a placeholder into the World resources. That's
obviously not very useful for actually starting the game, so we also need a function to
do the actual building and update the resources. Not entirely coincidentally, that
function is the same as the other two places from which we currently update the map!
In other words, we can roll those into this function, too. So in the implementation of
State , we add:
Now we can get rid of the commented out code, and simplify our �rst call quite a bit:
gs.ecs.insert(Map::new(1));
gs.ecs.insert(Point::new(0, 0));
gs.ecs.insert(rltk::RandomNumberGenerator::new());
let player_entity = spawner::player(&mut gs.ecs, 0, 0);
gs.ecs.insert(player_entity);
gs.ecs.insert(RunState::MainMenu{ menu_selection:
gui::MainMenuSelection::NewGame });
gs.ecs.insert(gamelog::GameLog{ entries : vec!["Welcome to Rusty
Roguelike".to_string()] });
gs.ecs.insert(particle_system::ParticleBuilder::new());
gs.ecs.insert(rex_assets::RexAssets::new());
gs.generate_world_map(1);
We can also go to the various parts of the code that call the same code we just added
to generate_world_map and greatly simplify them by using the new function. We can
replace goto_next_level with:
fn goto_next_level(&mut self) {
// Delete entities that aren't the player or his/her equipment
let to_delete = self.entities_to_remove_on_level_change();
for target in to_delete {
self.ecs.delete_entity(target).expect("Unable to delete entity");
}
fn game_over_cleanup(&mut self) {
// Delete everything
let mut to_delete = Vec::new();
for e in self.ecs.entities().join() {
to_delete.push(e);
}
for del in to_delete.iter() {
self.ecs.delete_entity(*del).expect("Deletion failed");
}
And there we go - cargo run gives the same game we've had for a while, and we've
cut out a bunch of code. Refactors that make things smaller rock!
Making a generator
It's surprisingly di�cult to combine two paradigms, sometimes:
The graphical "tick" nature of RLTK (and the underlying GUI environment)
encourages you to do everything fast, in one fell swoop.
Actually visualizing progress while you generate a map encourages you to run in
lots of phases as a "state machine", yielding map results along the way.
My �rst thought was to use coroutines, speci�cally Generators. They really are ideal for
this type of thing: you can write code in a function that runs synchronously (in order)
and "yields" values as the computation continues. I even went so far as to get a
working implementation - but it required nightly support (unstable, un�nished Rust)
and didn't play nicely with web assembly. So I scrapped it. There's a lesson here:
sometimes the tooling isn't quite ready for what you really want!
Instead, I decided to go with a more traditional route. Maps can take a "snapshot"
while they generate, and that big pile of snapshots can be played frame-by-frame in
the visualizer. This isn't quite as nice as a coroutine, but it works and is stable. Those
are desirable traits!
To get started, we should make sure that visualizing map generation is entirely
optional. When you ship your game to players, you probably don't want to show them
the whole map while they get started - but while you are working on map algorithms,
it's very valuable. So towards the top of main.rs , we add a constant:
A constant is just that: a variable that cannot change once the program has started.
Rust makes read-only constants pretty easy, and the compiler generally optimizes
them out completely since the value is known ahead of time. In this case, we're stating
that a bool called SHOW_MAPGEN_VISUALIZER is true . The idea is that we can set it to
false when we don't want to display our map generation progress.
With that in place, it's time to add snapshot support to our map builder interface. In
map_builders/mod.rs we extend the interface a bit:
Notice the new entries: get_snapshot_history and take_snapshot . The former will
be used to ask the generator for its history of map frames; the latter tells generators
to support taking snapshots (and leaves it up to them how they do it).
This is a good time to mention one major di�erence between Rust and C++ (and other
languages that provide Object Oriented Programming support). Rust traits do not
support adding variables to the trait signature. So you can't include a
history : Vec<Map> within the trait, even if that's exactly what you're using to store
the snapshot in all the implementations. I honestly don't know why this is the case,
but it's workable - just an odd departure from OOP norms.
Notice that we've added history: Vec<Map> to the structure. It's what it says on the
tin: a vector (resizable array) of Map structures. The idea is that we'll keep adding
copies of the map into it for each "frame" of map generation.
This is very simple: we return a copy of the history vector to the caller. We also need:
fn take_snapshot(&mut self) {
if SHOW_MAPGEN_VISUALIZER {
let mut snapshot = self.map.clone();
for v in snapshot.revealed_tiles.iter_mut() {
*v = true;
}
self.history.push(snapshot);
}
}
We �rst check to see if we're using the snapshot feature (no point in wasting memory
if we aren't!). If we are, we take a copy of the current map, iterate every
revealed_tiles cell and set it to true (so the map render will display everything,
We can now call self.take_snapshot() at any point during map generation, and it
gets added as a frame to the map generator. In simple_map.rs we add a couple of
calls after we add rooms or corridors:
...
if ok {
apply_room_to_map(&mut self.map, &new_room);
self.take_snapshot();
if !self.rooms.is_empty() {
let (new_x, new_y) = new_room.center();
let (prev_x, prev_y) = self.rooms[self.rooms.len()-1].center();
if rng.range(0,1) == 1 {
apply_horizontal_tunnel(&mut self.map, prev_x, new_x, prev_y);
apply_vertical_tunnel(&mut self.map, prev_y, new_y, new_x);
} else {
apply_vertical_tunnel(&mut self.map, prev_y, new_y, prev_x);
apply_horizontal_tunnel(&mut self.map, prev_x, new_x, new_y);
}
}
self.rooms.push(new_room);
self.take_snapshot();
}
...
Visualization actually requires a few variables, but I ran into a problem: one of the
variables really should be the next state to which we transition after visualizing. We
might be building a new map from one of three sources (new game, game over, next
level) - and they have di�erent states following the generation. Unfortunately, you
can't put a second RunState into the �rst one - Rust gives you cycle errors, and it
won't compile. You can use a Box<RunState> - but that doesn't work with RunState
deriving from Copy ! I fought this for a while, and settled on adding to State instead:
We've added:
Since we've modi�ed State , we also have to modify our creation of the State
object:
We've made the next state the same as the starting state we have been using: so the
game will render map creation and then go to the menu. We can change our initial
state to MapGeneration :
gs.ecs.insert(RunState::MapGeneration{} );
Now we need to implement the renderer. In our tick function, we add the following
state:
match newrunstate {
RunState::MapGeneration => {
if !SHOW_MAPGEN_VISUALIZER {
newrunstate = self.mapgen_next_state.unwrap();
}
ctx.cls();
draw_map(&self.mapgen_history[self.mapgen_index], ctx);
self.mapgen_timer += ctx.frame_time_ms;
if self.mapgen_timer > 300.0 {
self.mapgen_timer = 0.0;
self.mapgen_index += 1;
if self.mapgen_index == self.mapgen_history.len() {
newrunstate = self.mapgen_next_state.unwrap();
}
}
}
...
1. If the visualizer isn't enabled, simply transition to the next state immediately.
2. Clear the screen.
3. Call draw_map , with the map history from our state - at the current frame.
4. Add the frame duration to the mapgen_timer , and if it is greater than 300ms:
1. Set the timer back to 0.
2. Increment the frame counter.
3. If the frame counter has reached the end of our history, transition to the
next game state.
The eagle-eyed reader will have noticed a subtle change here. draw_map didn't used
to take a map - it would pull it from the ECS! In map.rs , the beginning of draw_map
changes to:
draw_map(&self.ecs.fetch::<Map>(), ctx);
This is a tiny change that allowed us to render whatever Map structure we need!
Lastly, we need to actually give the visualizer some data to render. We adjust
generate_world_map to reset the various mapgen_ variables, clear the history, and
retrieve the snapshot history once it has run:
If you cargo run the project now, you get to watch the simple map generator build
your level before you start.
Wrap-Up
This �nishes building the test harness - you can watch maps spawn, which should
make generating maps (the topic of the next few chapters) a lot more intuitive.
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.
A popular method of map generation uses "binary space partition" to sub-divide your
map into rectangles of varying size, and then link the resulting rooms together into
corridors. You can go a long way with this method: Nethack uses it extensively,
Dungeon Crawl: Stone Soup uses it sometimes, and my project - One Knight in the
Dungeon - uses it for sewer levels. This chapter will use the visualizer from the
previous chapter to walk you through using this technique.
This is basically the same as the one from SimpleMapBuilder - and we've kept the
rooms vector, because this method uses a concept of rooms as well. We've added a
rects vector: the algorithm uses this a lot, so it's helpful to make it available
fn build_map(&mut self) {
// We should do something here
}
fn take_snapshot(&mut self) {
if SHOW_MAPGEN_VISUALIZER {
let mut snapshot = self.map.clone();
for v in snapshot.revealed_tiles.iter_mut() {
*v = true;
}
self.history.push(snapshot);
}
}
}
This is also pretty much the same as SimpleMapBuilder , but build_map has a
comment reminding us to write some code. If you ran the generator right now, you'd
get a solid blob of walls - and no content whatsoever.
We also need to implement a constructor for BspMapBuilder . Once again, it's basically
the same as SimpleMapBuilder :
impl BspDungeonBuilder {
pub fn new(new_depth : i32) -> BspDungeonBuilder {
BspDungeonBuilder{
map : Map::new(new_depth),
starting_position : Position{ x: 0, y : 0 },
depth : new_depth,
rooms: Vec::new(),
history: Vec::new(),
rects: Vec::new()
}
}
}
Once again, this isn't in the slightest bit random - but it's far easier to develop a
feature that always runs, rather than keeping trying until it picks the one we want to
debug!
fn build(&mut self) {
let mut rng = RandomNumberGenerator::new();
self.rects.clear();
self.rects.push( Rect::new(2, 2, self.map.width-5, self.map.height-5)
); // Start with a single map-sized rectangle
let first_room = self.rects[0];
self.add_subrects(first_room); // Divide the first room
if self.is_possible(candidate) {
apply_room_to_map(&mut self.map, &candidate);
self.rooms.push(candidate);
self.add_subrects(rect);
self.take_snapshot();
}
n_rooms += 1;
}
let start = self.rooms[0].center();
self.starting_position = Position{ x: start.0, y: start.1 };
}
1. We clear the rects structure we created as part of the builder. This will be used
to store rectangles derived from the overall map.
2. We create the "�rst room" - which is really the whole map. We've trimmed a bit
to add some padding to the sides of the map.
3. We call add_subrects , passing it the rectangle list - and the �rst room. We'll
implement that in a minute, but what it does is: it divides the rectangle into four
quadrants, and adds each of the quadrants to the rectangle list.
4. Now we setup a room counter, so we don't in�nitely loop.
5. While that counter is less than 240 (a relatively arbitrary limit that gives fun
results):
1. We call get_random_rect to retrieve a random rectangle from the
rectangles list.
2. We call get_random_sub_rect using this rectangle as an outer boundary. It
creates a random room from 3 to 10 tiles in size (on each axis), somewhere
within the parent rectangle.
3. We ask is_possible if the candidate can be drawn to the map; every tile
must be within the map boundaries, and not already a room. If it IS
possible:
1. We mark it on the map.
2. We add it to the rooms list.
3. We call add_subrects to sub-divide the rectangle we just used (not
the candidate!).
There's quite a few support functions in play here, so lets go through them.
The function add_subrects is core to the BSP (Binary Space Partition) approach: it
takes a rectangle, and divides the width and height in half. It then creates four new
rectangles, one for each quadrant of the original. These are added to the rects list.
Graphically:
############### ###############
# # # 1 + 2 #
# # # + #
# 0 # -> #+++++++++++++#
# # # 3 + 4 #
# # # + #
############### ###############
Next up is get_random_rect :
This is a simple function. If there is only one rectangle in the rects list, it returns the
�rst one. Otherwise, it rolls a dice for of 1d(size of rects list) and returns the
rectangle found at the random index.
Next up is get_random_sub_rect :
result
}
So this takes a rectangle as the parameter, and makes a mutable copy to use as the
result. It calculates the width and height of the rectangle, and then produces a random
width and height inside that rectangle - but no less than 3 tiles in size and no more
than 10 on each dimension. You can tweak those numbers to change your desired
room size. It then shunts the rectangle a bit, to provide some random placement
(otherwise, it would always be against the sides of the sub-rectangle). Finally, it
returns the result. Graphically:
############### ########
# # # 1 #
# # # #
# 0 # -> ########
# #
# #
###############
can_build
}
This is a little more complicated, but makes sense when you break it down:
So now that we've implemented all of these, the overall algorithm is more obvious:
This tends to give a nice spread of rooms, and they are guaranteed not to overlap.
Very Nethack like!
If you cargo run now, you will be in a room with no exits. You'll get to watch rooms
appear around the map in the visualizer. That's a great start.
Adding in corridors
Now, we sort the rooms by left coordinate. You don't have to do this, but it helps
make connected rooms line up.
self.rooms.sort_by(|a,b| a.x1.cmp(&b.x1) );
sort_by takes a closure - that is, an inline function (known as a "lambda" in other
languages) as a parameter. You could specify a whole other function if you wanted to,
or implement traits on Rect to make it sortable - but this is easy enough. It sorts by
comparing the x1 value of each rectangle.
This iterates the rooms list, ignoring the last one. It fetches the current room, and the
next one in the list and calculates a random location ( start_x / start_y and end_x /
end_y ) within each room. It then calls the mysterious draw_corridor function with
these coordinates. Draw corridor adds a line from the start to the end, using only
north/south or east/west (it can give 90-degree bends). It won't give you a staggered,
hard to navigate perfect line like Bresenham would. We also take a snapshot.
while x != x2 || y != y2 {
if x < x2 {
x += 1;
} else if x > x2 {
x -= 1;
} else if y < y2 {
y += 1;
} else if y > y2 {
y -= 1;
}
It takes a start and end point, and creates mutable x and y variables equal to the
starting location. Then it keeps going until x and y match end end of the line. For
each iteration, if x is less than the ending x - it goes left. If x is greater than the
ending x - it goes right. Same for y , but with up and down. This gives straight
corridors with a single corner.
We place the exit in the last room, guaranteeing that the poor player has a ways to
walk.
Now when you play, it's a coin toss what type of map you encounter. The spawn
functions for the types are the same - so we're not going to worry about map builder
Wrap-Up
You've refactored your map building into a new module, and built a simple BSP
(Binary Space Partitioning) based map. The game randomly picks a map type, and you
have more variety. The next chapter will further refactor map generation, and
introduce another technique.
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.
In the last chapter, we used binary space partition (BSP) to build a dungeon with
rooms. BSP is �exible, and can help you with a lot of problems; in this example, we're
going to modify BSP to design an interior dungeon - completely inside a rectangular
structure (for example, a castle) and with no wasted space other than interior walls.
The code for this chapter is converted from One Knight in the Dungeon's prison levels.
Sca�olding
We'll start by making a new �le, map_builders/bsp_interior.rs and putting in the
same initial boilerplate that we used in the previous chapter:
fn build_map(&mut self) {
// We should do something here
}
fn take_snapshot(&mut self) {
if SHOW_MAPGEN_VISUALIZER {
let mut snapshot = self.map.clone();
for v in snapshot.revealed_tiles.iter_mut() {
*v = true;
}
self.history.push(snapshot);
}
}
}
impl BspInteriorBuilder {
pub fn new(new_depth : i32) -> BspInteriorBuilder {
BspInteriorBuilder{
map : Map::new(new_depth),
starting_position : Position{ x: 0, y : 0 },
depth : new_depth,
rooms: Vec::new(),
history: Vec::new(),
rects: Vec::new()
}
}
}
We'll also change our random builder function in map_builders/mod.rs to once again
lie to the user and always "randomly" pick the new algorithm:
fn build(&mut self) {
let mut rng = RandomNumberGenerator::new();
self.rects.clear();
self.rects.push( Rect::new(1, 1, self.map.width-2, self.map.height-2)
); // Start with a single map-sized rectangle
let first_room = self.rects[0];
self.add_subrects(first_room, &mut rng); // Divide the first room
The add_subrects function in this case does all the hard work:
// Calculate boundaries
let width = rect.x2 - rect.x1;
let height = rect.y2 - rect.y1;
let half_width = width / 2;
let half_height = height / 2;
if split <= 2 {
// Horizontal split
let h1 = Rect::new( rect.x1, rect.y1, half_width-1, height );
self.rects.push( h1 );
if half_width > MIN_ROOM_SIZE { self.add_subrects(h1, rng); }
let h2 = Rect::new( rect.x1 + half_width, rect.y1, half_width,
height );
self.rects.push( h2 );
if half_width > MIN_ROOM_SIZE { self.add_subrects(h2, rng); }
} else {
// Vertical split
let v1 = Rect::new( rect.x1, rect.y1, width, half_height-1 );
self.rects.push(v1);
if half_height > MIN_ROOM_SIZE { self.add_subrects(v1, rng); }
let v2 = Rect::new( rect.x1, rect.y1 + half_height, width,
half_height );
self.rects.push(v2);
if half_height > MIN_ROOM_SIZE { self.add_subrects(v2, rng); }
}
}
1. If the rects list isn't empty, we remove the last item from the list. This has the
e�ect of removing the last rectangle we added - so when we start, we are
removing the rectangle covering the whole map. Later on, we are removing a
rectangle because we are dividing it. This way, we won't have overlaps.
2. We calculate the width and height of the rectangle, and well as half of the width
and height.
3. We roll a dice. There's a 50% chance of a horizontal or vertical split.
4. If we're splitting horizontally:
1. We make h1 - a new rectangle. It covers the left half of the parent
rectangle.
2. We add h1 to the rects list.
3. If half_width is bigger than MIN_ROOM_SIZE , we recursively call
add_subrects again, with h1 as the target rectangle.
4. We make h2 - a new rectangle covering the right side of the parent
rectangle.
5. We add h2 to the rects list.
6. If half_width is bigger than MIN_ROOM_SIZE , we recursively call
add_subrects again, with h2 as the target rectangle.
5. If we're splitting vertically, it's the same as (4) - but with top and bottom
rectangles.
#################################
# #
# #
# #
# #
# #
# #
# #
# #
# #
#################################
#################################
# # #
# # #
# # #
# # #
# # #
# # #
# # #
# # #
# # #
#################################
#################################
# # #
# # #
# # #
# # #
################ #
# # #
# # #
# # #
# # #
#################################
You can cargo run the code right now, to see the rooms appearing.
while x != x2 || y != y2 {
if x < x2 {
x += 1;
} else if x > x2 {
x -= 1;
} else if y < y2 {
y += 1;
} else if y > y2 {
y -= 1;
}
We place the exit in the last room, guaranteeing that the poor player has a ways to
walk.
Wrap Up
This type of dungeon can represent an interior, maybe of a space ship, a castle, or
even a home. You can tweak dimensions, door placement, and bias the splitting as
you see �t - but you'll get a map that makes most of the available space usable by the
game. It's probably worth being sparing with these levels (or incorporating them into
other levels) - they can lack variety, even though they are random.
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.
Sometimes, you need a break from rectangular rooms. You might want a nice, organic
looking cavern; a winding forest trail, or a spooky quarry. One Knight in the Dungeon
uses cellular automata for this purpose, inspired by this excellent article. This chapter
will help you create natural looking maps.
Sca�olding
Once again, we're going to take a bunch of code from the previous tutorial and re-use
it for the new generator. Create a new �le, map_builders/cellular_automota.rd and
place the following in it:
fn build_map(&mut self) {
//self.build(); - we should write this
}
fn take_snapshot(&mut self) {
if SHOW_MAPGEN_VISUALIZER {
let mut snapshot = self.map.clone();
for v in snapshot.revealed_tiles.iter_mut() {
*v = true;
}
self.history.push(snapshot);
}
}
}
impl CellularAutomotaBuilder {
pub fn new(new_depth : i32) -> CellularAutomotaBuilder {
CellularAutomotaBuilder{
map : Map::new(new_depth),
starting_position : Position{ x: 0, y : 0 },
depth : new_depth,
history: Vec::new(),
}
}
}
Once again, we'll make the name random_builder a lie and only return the one we're
working on:
fn build(&mut self) {
let mut rng = RandomNumberGenerator::new();
This makes a mess of an unusable level. Walls and �oors everywhere with no rhyme or
reason to them - and utterly unplayable. That's ok, because cellular automata is
designed to make a level out of noise. It works by iterating each cell, counting the
number of neighbors, and turning walls into �oors or walls based on density. Here's a
working builder:
fn build(&mut self) {
let mut rng = RandomNumberGenerator::new();
for y in 1..self.map.height-1 {
for x in 1..self.map.width-1 {
let idx = self.map.xy_idx(x, y);
let mut neighbors = 0;
if self.map.tiles[idx - 1] == TileType::Wall { neighbors
+= 1; }
if self.map.tiles[idx + 1] == TileType::Wall { neighbors
+= 1; }
if self.map.tiles[idx - self.map.width as usize] ==
TileType::Wall { neighbors += 1; }
if self.map.tiles[idx + self.map.width as usize] ==
TileType::Wall { neighbors += 1; }
if self.map.tiles[idx - (self.map.width as usize - 1)] ==
TileType::Wall { neighbors += 1; }
if self.map.tiles[idx - (self.map.width as usize + 1)] ==
TileType::Wall { neighbors += 1; }
if self.map.tiles[idx + (self.map.width as usize - 1)] ==
TileType::Wall { neighbors += 1; }
if self.map.tiles[idx + (self.map.width as usize + 1)] ==
TileType::Wall { neighbors += 1; }
}
}
}
self.map.tiles = newtiles.clone();
self.take_snapshot();
}
}
This is a very simple algorithm - but produces quite beautiful results. Here it is in
action:
// Find a starting point; start at the middle and walk left until we find
an open tile
self.starting_position = Position{ x: self.map.width / 2, y :
self.map.height / 2 };
let mut start_idx = self.map.xy_idx(self.starting_position.x,
self.starting_position.y);
while self.map.tiles[start_idx] != TileType::Floor {
self.starting_position.x -= 1;
start_idx = self.map.xy_idx(self.starting_position.x,
self.starting_position.y);
}
self.map.tiles[exit_tile.0] = TileType::DownStairs;
self.take_snapshot();
This is a dense piece of code that does a lot, lets walk through it:
1. We create a vector called map_starts and give it a single value: the tile index on
which the player starts. Dijkstra maps can have multiple starting points (distance
0), so this has to be a vector even though there is only one choice.
2. We ask RLTK to make a Dijkstra Map for us. It has dimensions that match the
main map, uses the starts, has read access to the map itself, and we'll stop
If you cargo run , you actually have quite a playable map now! There's just one
problem: there are no other entities on the map.
As a �rst step, we're going to revisit how we spawn entities. Right now, pretty much
everything that isn't the player arrives into the world via the spawner.rs -provided
spawn_room function. It has served us well up to now, but we want to be a bit more
�exible; we might want to spawn in corridors, we might want to spawn in semi-open
areas that don't �t a rectangle, and so on. Also, a look over spawn_room shows that it
does several things in one function - which isn't the best design. A �nal objective is to
keep the spawn_room interface available - so we can still use it, but to also o�er more
detailed options.
match spawn.1.as_ref() {
"Goblin" => goblin(ecs, x, y),
"Orc" => orc(ecs, x, y),
"Health Potion" => health_potion(ecs, x, y),
"Fireball Scroll" => fireball_scroll(ecs, x, y),
"Confusion Scroll" => confusion_scroll(ecs, x, y),
"Magic Missile Scroll" => magic_missile_scroll(ecs, x, y),
"Dagger" => dagger(ecs, x, y),
"Shield" => shield(ecs, x, y),
"Longsword" => longsword(ecs, x, y),
"Tower Shield" => tower_shield(ecs, x, y),
"Rations" => rations(ecs, x, y),
"Magic Mapping Scroll" => magic_mapping_scroll(ecs, x, y),
"Bear Trap" => bear_trap(ecs, x, y),
_ => {}
}
}
Now we can replace the last for loop in spawn_room with the following:
Now, we'll replace spawn_room with a simpli�ed version that calls our theoretical
function:
This function maintains the same interface/signature as the previous call - so our old
code will still work. Instead of actually spawning anything, it builds a vector of all of
the tiles in the room (checking that they are �oors - something we didn't do before;
monsters in walls is no longer possible!). It then calls a new function, spawn_region
that accepts a similar signature - but wants a list of available tiles into which it can
spawn things. Here's the new function:
for _i in 0 .. num_spawns {
let array_index = if areas.len() == 1 { 0usize } else {
(rng.roll_dice(1, areas.len() as i32)-1) as usize };
let map_idx = areas[array_index];
spawn_points.insert(map_idx, spawn_table.roll(&mut rng));
areas.remove(array_index);
}
}
This is similar to the previous spawning code, but not quite the same (although the
results are basically the same!). We'll go through it, just to be sure we understand
what we're doing:
The best way to test this is to uncomment out the random_builder code (and
comment the CellularAutomotaBuilder entry) and give it a go. It should play just like
before. Once you've tested it, go back to always spawning the map type we're working
on.
First of all, what is noise. "Noise" in this case doesn't refer to the loud heavy metal you
accidentally pipe out of your patio speakers at 2am while wondering what a stereo
receiver you found in your new house does (true story...); it refers to random data -
like the noise on old analog TVs if you didn't tune to a station (ok, I'm showing my age
there). Like most things random, there's lots of ways to make it not-really-random and
group it into useful patterns. A noise library provides lots of types of noise.
Perlin/Simplex noise makes really good approximations of landscapes. White noise
looks like someone randomly threw paint at a piece of paper. Cellular Noise randomly
places points on a grid, and then plots Voronoi diagrams around them. We're
interested in the latter.
This is a somewhat complicated way to do things, so we'll take it a step at a time. Lets
start by adding a structure to store generated areas into our
CellularAutomotaBuilder structure:
impl CellularAutomotaBuilder {
pub fn new(new_depth : i32) -> CellularAutomotaBuilder {
CellularAutomotaBuilder{
map : Map::new(new_depth),
starting_position : Position{ x: 0, y : 0 },
depth : new_depth,
history: Vec::new(),
noise_areas : HashMap::new()
}
}
...
The idea here is that we have a HashMap (dictionary in other languages) keyed on the
ID number of an area. The area consists of a vector of tile ID numbers. Ideally, we'd
generate 20-30 distinct areas all with spaces to spawn entities into.
for y in 1 .. self.map.height-1 {
for x in 1 .. self.map.width-1 {
let idx = self.map.xy_idx(x, y);
if self.map.tiles[idx] == TileType::Floor {
let cell_value_f = noise.get_noise(x as f32, y as f32) *
10240.0;
let cell_value = cell_value_f as i32;
if self.noise_areas.contains_key(&cell_value) {
self.noise_areas.get_mut(&cell_value).unwrap().push(idx);
} else {
self.noise_areas.insert(cell_value, vec![idx]);
}
}
}
}
multiply by 10240.0 because the default is very small numbers - and this
brings it up into a reasonable range.
4. We convert the result to an integer.
5. If the noise_areas map contains the area number we just generated, we
add the tile index to the vector.
6. If the noise_areas map DOENS'T contain the area number we just
generated, we make a new vector of tile indices with the map index
number in it.
This generates between 20 and 30 areas quite consistently, and they only contain
valid �oor tiles. So the last remaining job is to actually spawn some entities. We
update our spawn_entities function:
This is quite simple: it iterates through each area, and calls the new spawn_region
with the vector of available map tiles for that region.
Restoring randomness
Once again, we should restore randomness to our map building. In
map_builders/mod.rs :
Wrap-Up
We've made a pretty nice map generator, and �xed our dependency upon rooms.
Cellular Automata are a really �exible algorithm, and can be used for all kinds of
organic looking maps. With a bit of tweaking to the rules, you can make a really large
variety of maps.
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.
Ever wondered what would happen if an Umber Hulk (or other tunneling creature) got
really drunk, and went on a dungeon carving bender? The Drunkard's Walk algorithm
answers the question - or more precisely, what would happen if a whole bunch of
monsters had far too much to drink. As crazy it sounds, this is a good way to make
organic dungeons.
Initial sca�olding
As usual, we'll start with sca�olding from the previous map tutorials. We've done it
enough that it should be old hat by now! In map_builders/drunkard.rs , build a new
DrunkardsWalkBuilder class. We'll keep the zone-based placement from Cellular
Automata - but remove the map building code. Here's the sca�olding:
fn build_map(&mut self) {
self.build();
}
fn take_snapshot(&mut self) {
if SHOW_MAPGEN_VISUALIZER {
let mut snapshot = self.map.clone();
for v in snapshot.revealed_tiles.iter_mut() {
*v = true;
}
self.history.push(snapshot);
}
}
}
impl DrunkardsWalkBuilder {
pub fn new(new_depth : i32) -> DrunkardsWalkBuilder {
DrunkardsWalkBuilder{
map : Map::new(new_depth),
starting_position : Position{ x: 0, y : 0 },
depth : new_depth,
history: Vec::new(),
noise_areas : HashMap::new()
}
}
#[allow(clippy::map_entry)]
fn build(&mut self) {
let mut rng = RandomNumberGenerator::new();
noise.set_cellular_distance_function(rltk::CellularDistanceFunction::Manhattan);
for y in 1 .. self.map.height-1 {
for x in 1 .. self.map.width-1 {
let idx = self.map.xy_idx(x, y);
if self.map.tiles[idx] == TileType::Floor {
let cell_value_f = noise.get_noise(x as f32, y as f32)
* 10240.0;
let cell_value = cell_value_f as i32;
if self.noise_areas.contains_key(&cell_value) {
self.noise_areas.get_mut(&cell_value).unwrap().push(idx);
} else {
self.noise_areas.insert(cell_value, vec![idx]);
}
}
}
}
}
}
We've kept a lot of the work from the Cellular Automata chapter, since it can help us
here also. We also go into map_builders/mod.rs and once again force the "random"
system to pick our new code:
/// Searches a map, removes unreachable areas and returns the most distant
tile.
pub fn remove_unreachable_areas_returning_most_distant(map : &mut Map,
start_idx : usize) -> usize {
map.populate_blocked();
let map_starts : Vec<i32> = vec![start_idx as i32];
let dijkstra_map = rltk::DijkstraMap::new(map.width, map.height,
&map_starts , map, 200.0);
let mut exit_tile = (0, 0.0f32);
for (i, tile) in map.tiles.iter_mut().enumerate() {
if *tile == TileType::Floor {
let distance_to_start = dijkstra_map.map[i];
// We can't get to this tile - so we'll make it a wall
if distance_to_start == std::f32::MAX {
*tile = TileType::Wall;
} else {
// If it is further away than our current exit candidate,
move the exit
if distance_to_start > exit_tile.1 {
exit_tile.0 = i;
exit_tile.1 = distance_to_start;
}
}
}
}
exit_tile.0
}
noise.set_cellular_distance_function(rltk::CellularDistanceFunction::Manhattan);
for y in 1 .. map.height-1 {
for x in 1 .. map.width-1 {
let idx = map.xy_idx(x, y);
if map.tiles[idx] == TileType::Floor {
let cell_value_f = noise.get_noise(x as f32, y as f32) *
10240.0;
let cell_value = cell_value_f as i32;
if noise_areas.contains_key(&cell_value) {
noise_areas.get_mut(&cell_value).unwrap().push(idx);
} else {
noise_areas.insert(cell_value, vec![idx]);
}
}
}
}
noise_areas
}
Plugging these into our build function lets us reduce the boilerplate section
considerably:
In the example, I've gone back to the cellular_automata section and done the same.
This is basically the same code we had before (hence, it isn't explained here), but
wrapped in a function (and taking a mutable map reference - so it changes the map
you give it, and the starting point as parameters).
Walking Drunkards
The basic idea behind the algorithm is simple:
That's really all there is to it: we keep spawning drunkards until we have su�cient
drunk_life -= 1;
}
if did_something {
self.take_snapshot();
active_digger_count += 1;
}
digger_count += 1;
for t in self.map.tiles.iter_mut() {
if *t == TileType::DownStairs {
*t = TileType::Floor;
}
}
floor_tile_count = self.map.tiles.iter().filter(|a| **a ==
TileType::Floor).count();
}
println!("{} dwarves gave up their sobriety, of whom {} actually found a
wall.", digger_count, active_digger_count);
This implementation expands a lot of things out, and could be much shorter - but for
clarity, we've left it large and obvious. We've also made a bunch of things into
variables that could be constants - it's easier to read, and is designed to be easy to
"play" with values. It also prints a status update to the console, showing what
happened.
If you cargo run now, you'll get a pretty nice open map:
map types. Since these can produce radically di�erent maps, lets customize the
interface to the algorithm to provide a few di�erent ways to run. We'll start by
creating a struct to hold the parameter sets:
Now we'll modify new and the structure itself to accept it:
...
impl DrunkardsWalkBuilder {
pub fn new(new_depth : i32, settings: DrunkardSettings) ->
DrunkardsWalkBuilder {
DrunkardsWalkBuilder{
map : Map::new(new_depth),
starting_position : Position{ x: 0, y : 0 },
depth : new_depth,
history: Vec::new(),
noise_areas : HashMap::new(),
settings
}
}
...
Box::new(DrunkardsWalkBuilder::new(new_depth, DrunkardSettings{
spawn_mode: DrunkSpawnMode::StartingPoint }))
...
while floor_tile_count < desired_floor_tiles {
let mut did_something = false;
let mut drunk_x;
let mut drunk_y;
match self.settings.spawn_mode {
DrunkSpawnMode::StartingPoint => {
drunk_x = self.starting_position.x;
drunk_y = self.starting_position.y;
}
DrunkSpawnMode::Random => {
if digger_count == 0 {
drunk_x = self.starting_position.x;
drunk_y = self.starting_position.y;
} else {
drunk_x = rng.roll_dice(1, self.map.width - 3) + 1;
drunk_y = rng.roll_dice(1, self.map.height - 3) + 1;
}
}
}
let mut drunk_life = 400;
...
This is a relatively easy change: if we're in "random" mode, the starting position for
the drunkard is the center of the map for the �rst digger (to ensure that we have
some space around the stairs), and then a random map location for each subsequent
This is a much more spread out map. Less of a big central area, and more like a
sprawling cavern. A handy variation!
Box::new(DrunkardsWalkBuilder::new(new_depth, DrunkardSettings{
spawn_mode: DrunkSpawnMode::Random,
drunken_lifetime: 100
}))
That's a simple change - and drastically alters the nature of the resulting map. Each
digger can only go one quarter the distance of the previous ones (stronger beer!), so
they tend to carve out less of the map. That leads to more iterations, and since they
start randomly you tend to see more distinct map areas forming - and hope they join
up (if they don't, they will be culled at the end).
cargo run with the 100 lifespan, randomly placed drunkards produces something
like this:
Box::new(DrunkardsWalkBuilder::new(new_depth, DrunkardSettings{
spawn_mode: DrunkSpawnMode::Random,
drunken_lifetime: 200,
floor_percent: 0.4
}))
If you cargo run now, you'll see that we have even fewer open areas forming:
starting_position : Position{ x: 0, y : 0 },
depth : new_depth,
history: Vec::new(),
noise_areas : HashMap::new(),
settings : DrunkardSettings{
spawn_mode: DrunkSpawnMode::Random,
drunken_lifetime: 100,
floor_percent: 0.4
}
}
}
Now we can modify our random_builder function to be once again random - and
o�er three di�erent map types:
Wrap-Up
And we're done with drunken map building (words I never expected to type...)! It's a
very �exible algorithm, and can be used to make a lot of di�erent map types. It also
combines well with other algorithms, as we'll see in future chapters.
Maze/Labyrinth Generation
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.
Sca�olding
Once again, we'll use the previous chapter as sca�olding - and set our "random"
builder to use the new design. In map_builders/maze.rs , place the following code:
fn build_map(&mut self) {
self.build();
}
fn take_snapshot(&mut self) {
if SHOW_MAPGEN_VISUALIZER {
let mut snapshot = self.map.clone();
for v in snapshot.revealed_tiles.iter_mut() {
*v = true;
}
self.history.push(snapshot);
}
}
}
impl MazeBuilder {
pub fn new(new_depth : i32) -> MazeBuilder {
MazeBuilder{
map : Map::new(new_depth),
starting_position : Position{ x: 0, y : 0 },
depth : new_depth,
history: Vec::new(),
noise_areas : HashMap::new()
}
}
#[allow(clippy::map_entry)]
fn build(&mut self) {
let mut rng = RandomNumberGenerator::new();
// Find a starting point; start at the middle and walk left until
we find an open tile
self.starting_position = Position{ x: self.map.width / 2, y :
self.map.height / 2 };
let mut start_idx = self.map.xy_idx(self.starting_position.x,
self.starting_position.y);
while self.map.tiles[start_idx] != TileType::Floor {
self.starting_position.x -= 1;
start_idx = self.map.xy_idx(self.starting_position.x,
self.starting_position.y);
}
self.take_snapshot();
The algorithm started as C++ code with pointers everywhere, and took a bit of time to
port. The most basic structure in the algorithm: the Cell . Cells are tiles on the map:
#[derive(Copy, Clone)]
struct Cell {
row: i32,
column: i32,
walls: [bool; 4],
visited: bool,
}
We de�ne four constants: TOP, RIGHT, BOTTOM and LEFT and assign them to the
numbers 0..3 . We use these whenever the algorithm wants to refer to a direction.
Looking at Cell , it is relatively simple:
impl Cell {
fn new(row: i32, column: i32) -> Cell {
Cell{
row,
column,
walls: [true, true, true, true],
visited: false
}
}
...
This is a simple constructor: it makes a cell with walls in each direction, and not
previously visited. Cells also de�ne a function called remove_walls :
if x == 1 {
self.walls[LEFT] = false;
(*(next)).walls[RIGHT] = false;
}
else if x == -1 {
self.walls[RIGHT] = false;
(*(next)).walls[LEFT] = false;
}
else if y == 1 {
self.walls[TOP] = false;
(*(next)).walls[BOTTOM] = false;
}
else if y == -1 {
self.walls[BOTTOM] = false;
(*(next)).walls[TOP] = false;
}
}
The terrifying looking unsafe keyword says "this function uses code that Rust
cannot prove to be memory-safe". In other words, it is the Rust equivalent of
typing here be dragons .
We need the unsafe �ag because of next : *mut Cell . We've not used a raw
pointer before - we're into C/C++ land, now! The syntax is relatively
straightforward; the * indicates that it is a pointer (rather than a reference). The
mut indicates that we can change the contents (that's what makes it unsafe;
Rust can't verify that the pointer is actually valid, so if we change something we
might really break things!), and it points to a Cell . A reference is actually a type
of pointer; it points to a variable, and if you access it - you are reading the
variable to which it points (so there's only one copy existing). With a reference,
you can change the value with *myref = 1 - the * dereferences the reference,
giving you the original area of memory. Rust can check that this is valid with the
borrow checker. More on this in a moment.
We set x to be our column value, minus the column value of the cell we are
pointing at. The (*(next)).column is horrible syntax, and should discourage
anyone from using pointers (I think that's the point). The �rst parentheses
indicate that we're modifying the type; the * dereferences the pointer encased
in the �nal set of parentheses. Since we're changing a value pointed to by a
pointer, this is inherently unsafe (and makes the function not compile if we don't
use the unsafe �ag), and also means: BE REALLY CAREFUL.
We do the same with y - but with row values.
If x is equal to 1, then the pointer's column must be greater than our column
value. In other words, the next cell is to the right of our current location. So we
remove the wall to the right.
Likewise, if x is -1 , then we must be going left - so we remove the wall to the
right.
Once again, if y is 1 , we must be going up. So we remove the walls to the top.
Finally, if y is -1 , we must be going down - so we remove the walls below us.
Whew! Cell is done. Now to actually use it. In our maze algorithm, Cell is part of
Grid . Here's the basic Grid de�nition:
struct Grid<'a> {
width: i32,
height: i32,
cells: Vec<Cell>,
backtrace: Vec<usize>,
current: usize,
rng : &'a mut RandomNumberGenerator
}
The <'a> is a lifetime speci�er. We have to specify one so that Rust's borrow
checker can ensure that the Grid will not expire before we delete the
RandomNumberGenerator . Because we're passing a mutable reference to the
caller's RNG, Rust needs this to ensure that the RNG doesn't go away before
we're �nished with it. This type of bug often a�ects C/C++ users, so Rust made it
really hard to mess up. Unfortunately, the price of making it hard to get wrong is
some ugly syntax!
We have a width and height de�ning the size of the maze.
Cells are just a Vector of the Cell type we de�ned earlier.
backtrace is used by the algorithm for recursively back-tracking to ensure that
every cell has been processed. It's just a vector of cell indices - the index into
the cells vector.
current is used by the algorithm to tell which Cell we're currently working
with.
rng is the reason for the ugly lifetime stu�; we want to use the random number
generator built in the build function, so we store a reference to it here.
Because obtaining a random number changes the content of the variable, we
have to store a mutable reference. The really ugly &'a mut indicates that it is a
reference, with the lifetime 'a (de�ned above) and is mutable/changeable.
impl<'a> Grid<'a> {
fn new(width: i32, height:i32, rng: &mut RandomNumberGenerator) ->
Grid {
let mut grid = Grid{
width,
height,
cells: Vec::new(),
backtrace: Vec::new(),
current: 0,
rng
};
grid
}
...
Notice that once again we had to use some ugly syntax for the lifetime! The
constructor itself is quite simple: it makes a new Grid structure with the speci�ed
width and height , a new vector of cells, a new (empty) backtrace vector, sets
current to 0 and stores the random number generator reference. Then it iterates
the rows and columns of the grid, pushing new Cell structures to the cells vector,
numbered by their location.
This is very similar to our map 's xy_idx function: it takes a row and column
coordinate, and returns the array index at which one can �nd the cell. It also does
some bounds checking, and returns -1 if the coordinates are invalid. Next, we
provide get_available_neighbors :
for i in neighbor_indices.iter() {
if *i != -1 && !self.cells[*i as usize].visited {
neighbors.push(*i as usize);
}
}
neighbors
}
This function provides the available exits from the current cell. It works by obtaining
the row and column coordinates of the current cell, and then puts a call to
calculate_index into an array (corresponding to the directions we de�ned with
Cell ). It �nally iterates the array, and if the values are valid (greater than -1 ), and we
haven't been there before (the visited check) it pushes them into the neighbors list.
It then returns neighbors . A call to this for any cell address will return a vector
listing all of the adjacent cells to which we can travel (ignoring walls). We �rst use this
in find_next_cell :
This function is interesting in that it returns an Option . It's possible that there is
nowhere to go from the current cell - in which case it returns None . Otherwise, it
returns Some with the array index of the next destination. It works by:
match next {
Some(next) => {
self.cells[next].visited = true;
self.backtrace.insert(0, self.current);
unsafe {
let next_cell : *mut Cell = &mut self.cells[next];
let current_cell = &mut self.cells[self.current];
current_cell.remove_walls(next_cell);
}
self.current = next;
}
None => {
if !self.backtrace.is_empty() {
self.current = self.backtrace[0];
self.backtrace.remove(0);
} else {
break;
}
}
}
self.copy_to_map(&mut generator.map);
generator.take_snapshot();
}
}
So now we're onto the actual algorithm! Lets step through it to understand how it
works:
1. We start with a loop . We haven't used one of these before (you can read about
them here). Basically, a loop runs forever - until it hits a break statement.
2. We set the value of visited in the current cell to true .
3. We add the current cell to the beginning of the backtrace list.
4. We call find_next_cell and set its index in the variable next . If this is our �rst
run, we'll get a random direction from the starting cell. Otherwise, we get an exit
from the current cell we're visiting.
5. If next has a value, then:
The �rst few iterations will get a non-visited neighbor, carving a clear path
through the maze. Each step along the way, the cell we've visited is added to
backtrace . This is e�ectively a drunken walk through the maze, but ensuring
that we cannot return to a cell.
When we hit a point at which we have no neighbors (we've hit the end of the
maze), the algorithm will change current to the �rst entry in our backtrace
list. It will then randomly walk from there, �lling in more cells.
If that point can't go anywhere, it works back up the backtrace list.
This repeats until every cell has been visited, meaning that backtrace and
neighbors are both empty. We're done!
map.tiles[idx] = TileType::Floor;
if !cell.walls[TOP] { map.tiles[idx - map.width as usize] =
TileType::Floor }
if !cell.walls[RIGHT] { map.tiles[idx + 1] = TileType::Floor }
if !cell.walls[BOTTOM] { map.tiles[idx + map.width as usize] =
TileType::Floor }
if !cell.walls[LEFT] { map.tiles[idx - 1] = TileType::Floor }
}
}
This is where the mismatch between Grid/Cell and our map format is resolved:
each Cell in the maze structure can have walls in any of the four major directions.
Our map doesn't work that way: walls aren't part of a tile, they are a tile. So we double
the size of the Grid , and write carve �oors where walls aren't present. Lets walk
through this function:
match next {
Some(next) => {
self.cells[next].visited = true;
self.backtrace.insert(0, self.current);
unsafe {
let next_cell : *mut Cell = &mut self.cells[next];
let current_cell = &mut self.cells[self.current];
current_cell.remove_walls(next_cell);
}
self.current = next;
}
None => {
if !self.backtrace.is_empty() {
self.current = self.backtrace[0];
self.backtrace.remove(0);
} else {
break;
}
}
}
if i % 50 == 0 {
self.copy_to_map(&mut generator.map);
generator.take_snapshot();
}
i += 1;
}
}
This brings the generator up to a reasonable speed, and you can still watch the maze
develop.
self.starting_position = Position{ x: 2, y : 2 };
let start_idx = self.map.xy_idx(self.starting_position.x,
self.starting_position.y);
self.take_snapshot();
We can then use the same code we've used in the last two examples to �nd an exit:
This is also a great test of the library's Dijkstra map code. It can solve a maze very
quickly!
Wrap-Up
In this chapter, we've built a maze. It's a guaranteed solvable maze, so there's no risk
of a level that you can't beat. You still have to use this type of map with caution: they
make good one-o� maps, and can really annoy players!
Di�usion-Limited Aggregation
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.
Sca�olding
We'll create a new �le, map_builders/dla.rs and put the sca�olding in from previous
projects. We'll name the builder DLABuilder . We'll also keep the voronoi spawn code,
it will work �ne for this application. Rather than repeat the sca�olding code blocks
from previous chapters, we'll jump straight in. If you get stuck, you can check the
source code for this chapter here.
This should be pretty self-explanatory by now if you've been through the other
chapters:
We'll make some type constructors once we've mastered the algorithms and their
variants!
Walking Inwards
The most basic form of Di�usion-Limited Aggregation works like this:
fn build(&mut self) {
let mut rng = RandomNumberGenerator::new();
// Random walker
let total_tiles = self.map.width * self.map.height;
let desired_floor_tiles = (self.floor_percent * total_tiles as f32) as
usize;
let mut floor_tile_count = self.map.tiles.iter().filter(|a| **a ==
TileType::Floor).count();
while floor_tile_count < desired_floor_tiles {
match self.algorithm {
DLAAlgorithm::WalkInwards => {
let mut digger_x = rng.roll_dice(1, self.map.width - 3) +
1;
let mut digger_y = rng.roll_dice(1, self.map.height - 3) +
1;
let mut prev_x = digger_x;
let mut prev_y = digger_y;
let mut digger_idx = self.map.xy_idx(digger_x, digger_y);
while self.map.tiles[digger_idx] == TileType::Wall {
prev_x = digger_x;
prev_y = digger_y;
let stagger_direction = rng.roll_dice(1, 4);
match stagger_direction {
1 => { if digger_x > 2 { digger_x -= 1; } }
2 => { if digger_x < self.map.width-2 { digger_x
+= 1; } }
3 => { if digger_y > 2 { digger_y -=1; } }
_ => { if digger_y < self.map.height-2 { digger_y
+= 1; } }
}
digger_idx = self.map.xy_idx(digger_x, digger_y);
}
self.paint(prev_x, prev_y);
}
_ => {}
...
The only new thing here is the call to paint . We'll be extending it later (to handle
brush sizes), but here's a temporary implementation:
If you cargo run this, you will get a pretty cool looking dungeon:
Walking outwards
A second variant of this algorithm reverses part of the process:
...
DLAAlgorithm::WalkOutwards => {
let mut digger_x = self.starting_position.x;
let mut digger_y = self.starting_position.y;
let mut digger_idx = self.map.xy_idx(digger_x, digger_y);
while self.map.tiles[digger_idx] == TileType::Floor {
let stagger_direction = rng.roll_dice(1, 4);
match stagger_direction {
1 => { if digger_x > 2 { digger_x -= 1; } }
2 => { if digger_x < self.map.width-2 { digger_x += 1; } }
3 => { if digger_y > 2 { digger_y -=1; } }
_ => { if digger_y < self.map.height-2 { digger_y += 1; } }
}
digger_idx = self.map.xy_idx(digger_x, digger_y);
}
self.paint(digger_x, digger_y);
}
_ => {}
There aren't any new concepts in this code, and if you understood Drunkard's Walk - it
should be pretty self explanatory. If you adjust the constructor to use it, and call
cargo run it looks pretty good:
Central Attractor
This variant is again very similar, but slightly di�erent. Instead of moving randomly,
your particles path from a random point towards the middle:
...
DLAAlgorithm::CentralAttractor => {
let mut digger_x = rng.roll_dice(1, self.map.width - 3) + 1;
let mut digger_y = rng.roll_dice(1, self.map.height - 3) + 1;
let mut prev_x = digger_x;
let mut prev_y = digger_y;
let mut digger_idx = self.map.xy_idx(digger_x, digger_y);
If you adjust the constructor to use this algorithm, and cargo run the project you get
a map that is more focused around a central point:
Implementing Symmetry
Symmetry can transform a random map into something that looks designed - but
quite alien. It often looks quite insectoid or reminiscent of a Space Invaders enemy.
This can make for some fun-looking levels!
This is a longer function that it really needs to be, in the name of clarity. Here's how it
works:
3. If it is Horizontal :
1. We check to see if we are on the tile - if we are, just apply the paint once.
2. Otherwise, obtain the horizontal distance from the center.
3. Paint at center_x - distance and center_x + distance to paint
symmetrically on the x axis.
4. If it is Vertical :
1. We check to see if we are on the tile - if we are, just apply the paint once
(this helps with odd numbers of tiles by reducing rounding issues).
2. Otherwise, obtain the vertical distance from the center.
3. Paint at center_y - distance and center_y + distance .
5. If it is Both - then do both steps.
You'll notice that we're calling apply_paint rather than actually painting. That's
because we've also implemented brush_size :
_ => {
let half_brush_size = self.brush_size / 2;
for brush_y in y-half_brush_size .. y+half_brush_size {
for brush_x in x-half_brush_size .. x+half_brush_size {
if brush_x > 1 && brush_x < self.map.width-1 &&
brush_y > 1 && brush_y < self.map.height-1 {
let idx = self.map.xy_idx(brush_x, brush_y);
self.map.tiles[idx] = TileType::Floor;
}
}
}
}
}
}
In your constructor, use the CentralAttractor algorithm - and enable symmetry with
Horizontal . If you cargo run now, you get a map not unlike a cranky insectoid:
With this simple change, our map looks much more open:
starting_position : Position{ x: 0, y : 0 },
depth : new_depth,
history: Vec::new(),
noise_areas : HashMap::new(),
algorithm: DLAAlgorithm::CentralAttractor,
brush_size: 2,
symmetry: DLASymmetry::Horizontal,
floor_percent: 0.25
}
}
Wrap-up
This chapter has introduced another, very �exible, map builder for your arsenal. Great
for making maps that feel like they were carved from the rock (or hewn from the
forest, mined from the asteroid, etc.), it's another great way to introduce variety into
your game.
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.
common.rs . We'll also change its name, since we are no longer binding it to a speci�c
algorithm:
map.tiles[digger_idx] = TileType::Floor;
}
_ => {
let half_brush_size = brush_size / 2;
for brush_y in y-half_brush_size .. y+half_brush_size {
for brush_x in x-half_brush_size .. x+half_brush_size {
if brush_x > 1 && brush_x < map.width-1 && brush_y > 1
&& brush_y < map.height-1 {
let idx = map.xy_idx(brush_x, brush_y);
map.tiles[idx] = TileType::Floor;
}
}
}
}
}
}
This shouldn't be a surprise: it's the exact same code we had in dla.rs - but with the
&mut self removed and instead taking parameters.
Like a lot of refactoring, the proof of the pudding is that if you cargo run your code -
nothing has changed! We won't bother with a screenshot to show that it's the same as
last time!
The compiler will complain that we aren't setting these in our constructors, so we'll
add some default values:
We need to make similar changes to the other constructors - just adding brush_size
and symmetry to each of the DrunkardSettings builders.
self.map.tiles[drunk_idx] = TileType::DownStairs;
With:
The double-draw retains the function of adding > symbols to show you the walker's
path, while retaining the overdraw of the paint function.
Notice how the "fatter" digging area gives more open halls. It also runs in half the
time, since we exhaust the desired �oor count much more quickly.
Adding Symmetry
Like DLA, symmetrical drunkards can make interesting looking maps. We'll add one
more constructor:
Notice how the symmetry is applied (really fast - we're blasting out the �oor tiles,
now!) - and then unreachable areas are culled, getting rid of part of the map. This is
quite a nice map!
Wrap-Up
This chapter has demonstrated a very useful tool for the game programmer: �nding a
handy algorithm, making it generic, and using it in other parts of your code. It's rare to
guess exactly what you need up-front (and there's a lot to be said for "you won't need
it" - implementing things when you do need them), so it's a valuable weapon in our
arsenal to be able to quickly refactor our code for reuse.
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've touched on Voronoi diagrams before, in our spawn placement. In this section,
we'll use them to make a map. The algorithm basically subdivides the map into
regions, and places walls between them. The result is a bit like a hive. You can play
with the distance/adjacency algorithm to adjust the results.
Sca�olding
We'll make sca�olding like in the previous chapters, making voronoi.rs with the
structure VoronoiBuilder in it. We'll also adjust our random_builder function to only
return VoronoiBuilder for now.
The �rst step in making some Voronoi noise it to populate a set of "seeds". These are
randomly chosen (but not duplicate) points on the map. We'll make the number of
seeds a variable so it can be tweaked later. Here's the code:
This makes a vector , each entry containing a tuple . Inside that tuple, we're storing
an index to the map location, and a Point with the x and y coordinates in it (we
could skip saving those and calculate from the index if we wanted, but I feel that this
is clearer). Then we randomly determine a position, check to see that we haven't
already rolled that location, and add it. We repeat the process until we have the
desired number of seeds. 64 is quite a lot, but will give a relatively dense hive-like
structure.
voroni_distance.sort_by(|a,b| a.1.partial_cmp(&b.1).unwrap());
You can summarize that in English more easily: each tile is given membership of the
Voronoi group to whom's seed it is physically closest.
for y in 1..self.map.height-1 {
for x in 1..self.map.width-1 {
let mut neighbors = 0;
let my_idx = self.map.xy_idx(x, y);
let my_seed = voronoi_membership[my_idx];
if voronoi_membership[self.map.xy_idx(x-1, y)] != my_seed {
neighbors += 1; }
if voronoi_membership[self.map.xy_idx(x+1, y)] != my_seed {
neighbors += 1; }
if voronoi_membership[self.map.xy_idx(x, y-1)] != my_seed {
neighbors += 1; }
if voronoi_membership[self.map.xy_idx(x, y+1)] != my_seed {
neighbors += 1; }
if neighbors < 2 {
self.map.tiles[my_idx] = TileType::Floor;
}
}
self.take_snapshot();
}
In this code, we visit every tile except for the very outer edges. We count how many
neighboring tiles are in a di�erent Voronoi group. If the answer is 0, then it is entirely
in the group: so we can place a �oor. If the answer is 1, it only borders 1 other group -
so we can also place a �oor (to ensure we can walk around the map). Otherwise, we
leave the tile as a wall.
Then we run the same culling and placement code we've used before. If you
cargo run the project now, you will see a pleasant structure:
fn build(&mut self) {
let mut rng = RandomNumberGenerator::new();
// Make a Voronoi diagram. We'll do this the hard way to learn about
the technique!
let mut voronoi_seeds : Vec<(usize, rltk::Point)> = Vec::new();
}
}
voroni_distance[seed] = (seed, distance);
}
voroni_distance.sort_by(|a,b| a.1.partial_cmp(&b.1).unwrap());
for y in 1..self.map.height-1 {
for x in 1..self.map.width-1 {
let mut neighbors = 0;
let my_idx = self.map.xy_idx(x, y);
let my_seed = voronoi_membership[my_idx];
if voronoi_membership[self.map.xy_idx(x-1, y)] != my_seed {
neighbors += 1; }
if voronoi_membership[self.map.xy_idx(x+1, y)] != my_seed {
neighbors += 1; }
if voronoi_membership[self.map.xy_idx(x, y-1)] != my_seed {
neighbors += 1; }
if voronoi_membership[self.map.xy_idx(x, y+1)] != my_seed {
neighbors += 1; }
if neighbors < 2 {
self.map.tiles[my_idx] = TileType::Floor;
}
}
self.take_snapshot();
}
...
As a test, lets change the constructor to use Manhattan distance. The results will look
something like this:
Notice how the lines are straighter, and less organic looking. That's what Manhattan
distance does: it calculates distance like a Manhattan Taxi Driver - number of rows
plus number of columns, rather than a straight line distance.
Restoring Randomness
So we'll put a couple of constructors in for each of the noise types:
Wrap-Up
That's another algorithm under our belts! We really have enough to write a pretty
good roguelike now, but there are still more to come!
Waveform Collapse
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.
A few years ago, Waveform Collapse (WFC) exploded onto the procedural generation
scene. Apparently magical, it took images in - and made a similar image. Demos
showed it spitting out great looking game levels, and the amazing Caves of Qud
started using it for generating fun levels. The canonical demonstrations - along with
the original algorithm in C# and various explanatory links/ports - may be found here.
In this chapter, we're going to implement Waveform Collapse from scratch - and apply
it to making fun Roguelike levels. Note that there is a crate with the original algorithm
available ( wfc , accompanied by wfc-image ); it seemed pretty good in testing, but I
had problems making it work with Web Assembly. I also didn't feel that I was really
teaching the algorithm by saying "just import this". It's a longer chapter, but by the end
you should feel comfortable with the algorithm.
1. It reads the incoming data. In the original implementation, this was a PNG �le. In
our implementation, this is a Map structure like others we've worked with; we'll
also implement a REX Paint reader to load maps.
2. It divides the source image into "tiles", and optionally makes more tiles by
mirroring the tiles it reads along one or two axes.
3. It either loads or builds a "constraints" graph. This is a set of rules specifying
which tiles can go next to each other. In an image, this may be derived from tile
adjacency. In a Roguelike map, connectivity of exits is a good metric. For a tile-
based game, you might carefully build a layout of what can go where.
4. It then divides the output image into tile-sized chunks, and sets them all to
"empty". The �rst tile placed will be pretty random, and then it selects areas and
examines tile data that is already known - placing down tiles that are compatible
with what is already there. Eventually, it's placed all of the tiles - and you have a
map/image!
The name "Waveform Collapse" refers to the Quantum Physics idea that a particle
may have not actually have a state until you look at it. In the algorithm, tiles don't
really coalesce into being until you pick one to examine. So there is a slight similarity to
Quantum Physics. In reality, though - the name is a triumph of marketing. The
algorithm is what is known as a solver - given a set of constraints, it iterates through
possible solutions until the constraints are solved. This isn't a new concept - Prolog is
an entire programming language based around this idea, and it �rst hit the scene in
1972. So in a way, it's older than me!
Rust makes it pretty easy to break any module into multiple �les: you create a
directory inside the parent module, and put a �le in it called mod.rs . You can then put
more �les in the folder, and so long as you enable them (with mod myfile ) and use
the contents (with use myfile::MyElement ) it works just like a single �le.
So to get started, inside your map_builders directory - make a new directory called
waveform_collapse . Add a �le, mod.rs into it. You should have a source tree like this:
\ src
\ map_builders
\ waveform_collapse
+ mod.rs
bsp_dungeon.rs
(etc)
main.rs
(etc)
fn build_map(&mut self) {
self.build();
}
fn take_snapshot(&mut self) {
if SHOW_MAPGEN_VISUALIZER {
let mut snapshot = self.map.clone();
for v in snapshot.revealed_tiles.iter_mut() {
*v = true;
}
self.history.push(snapshot);
}
}
}
impl WaveformCollapseBuilder {
pub fn new(new_depth : i32) -> WaveformCollapseBuilder {
WaveformCollapseBuilder{
map : Map::new(new_depth),
starting_position : Position{ x: 0, y : 0 },
depth : new_depth,
history: Vec::new(),
noise_areas : HashMap::new()
}
}
fn build(&mut self) {
let mut rng = RandomNumberGenerator::new();
// Find a starting point; start at the middle and walk left until
we find an open tile
self.starting_position = Position{ x: self.map.width / 2, y :
self.map.height / 2 };
/*let mut start_idx = self.map.xy_idx(self.starting_position.x,
self.starting_position.y);
while self.map.tiles[start_idx] != TileType::Floor {
self.starting_position.x -= 1;
start_idx = self.map.xy_idx(self.starting_position.x,
self.starting_position.y);
}*/
self.take_snapshot();
This will give you an empty map (all walls) if you cargo run it - but it's a good starting
point.
I've tried to include some interesting shapes, a silly face, and plenty of corridors and
di�erent sized rooms. Here's a second REX Paint �le, designed to be more like the old
board game The Sorcerer's Cave, of which the algorithm reminds me - tiles with 1 exit,
2 exits, 3 exits and 4. It would be easy to make these prettier, but we'll keep it simple
for demonstration purposes.
use rltk::{rex::XpFile};
rltk::embedded_resource!(SMALL_DUNGEON, "../../resources
/SmallDungeon_80x50.xp");
rltk::embedded_resource!(WFC_DEMO_IMAGE1, "../../resources/wfc-demo1.xp");
rltk::embedded_resource!(WFC_DEMO_IMAGE2, "../../resources/wfc-demo2.xp");
impl RexAssets {
#[allow(clippy::new_without_default)]
pub fn new() -> RexAssets {
rltk::link_resource!(SMALL_DUNGEON, "../../resources
/SmallDungeon_80x50.xp");
rltk::link_resource!(WFC_DEMO_IMAGE1, "../../resources/wfc-
demo1.xp");
rltk::link_resource!(WFC_DEMO_IMAGE2, "../../resources/wfc-
demo2.xp");
RexAssets{
menu : XpFile::from_resource("../../resources
/SmallDungeon_80x50.xp").unwrap()
}
}
}
Finally, we should load the map itself! Inside the waveform_collapse directory, make
a new �le: image_loader.rs :
use rltk::rex::XpFile;
use super::{Map, TileType};
/// Loads a RexPaint file, and converts it into our map format
pub fn load_rex_map(new_depth: i32, xp_file : &XpFile) -> Map {
let mut map : Map = Map::new(new_depth);
map
}
This is really simple, and if you remember the main menu graphic tutorial it should be
quite self-explanatory. This function:
1. Accepts arguments for new_depth (because maps want it) and a reference to an
XpFile - a REX Paint map. It will be made completely solid, walls everywhere by
the constructor.
2. It creates a new map, using the new_depth parameter.
3. For each layer in the REX Paint �le (there should be only one at this point):
1. For each y and x on that layer:
1. Load the tile information for that coordinate.
2. Ensure that we're within the map boundaries (in case we have a
mismatch in sizes).
3. Calculate the tiles index for the cell.
4. Match on the cell glyph; if its a # (35) we place a wall, if its a space
(32) we place a �oor.
Now we can modify our build function (in mod.rs ) to load the map:
fn build(&mut self) {
let mut rng = RandomNumberGenerator::new();
self.map = load_rex_map(self.depth,
&rltk::rex::XpFile::from_resource("../../resources/wfc-
demo1.xp").unwrap());
self.take_snapshot();
// Find a starting point; start at the middle and walk left until we
find an open tile
self.starting_position = Position{ x: self.map.width / 2, y :
self.map.height / 2 };
...
mod image_loader;
use image_loader::*;
Note that we're not putting pub in front of these: we're using them, but not exposing
them outside of the module. This helps us keep our code clean, and our compile
times short!
In and of itself, this is cool - we can now load any REX Paint designed level and play it!
If you cargo run now, you'll �nd that you can play the new map:
We'll make use of this in later chapters for vaults, prefabs and pre-designed levels - but
for now, we'll just use it as source data for later in the Waveform Collapse
implementation.
We'll start by picking a tile size (we're going to call it chunk_size ). We'll make it a
constant for now (it'll become tweakable later), and start with a size of 7 - because
that was the size of the tiles in our second REX demo �le. We'll also call a function we'll
write in a moment:
fn build(&mut self) {
let mut rng = RandomNumberGenerator::new();
self.map = load_rex_map(self.depth,
&rltk::rex::XpFile::from_resource("../../resources/wfc-
demo2.xp").unwrap());
self.take_snapshot();
Since we're dealing with constraints, we'll make a new �le in our
map_builders/waveform_collapse directory - constraints.rs . We're going to make
a function called build_patterns :
for cy in 0..chunks_y {
for cx in 0..chunks_x {
// Normal orientation
let mut pattern : Vec<TileType> = Vec::new();
let start_x = cx * chunk_size;
let end_x = (cx+1) * chunk_size;
let start_y = cy * chunk_size;
let end_y = (cy+1) * chunk_size;
if include_flipping {
// Flip horizontal
pattern = Vec::new();
for y in start_y .. end_y {
for x in start_x .. end_x {
let idx = map.xy_idx(end_x - (x+1), y);
pattern.push(map.tiles[idx]);
}
}
patterns.push(pattern);
// Flip vertical
pattern = Vec::new();
for y in start_y .. end_y {
for x in start_x .. end_x {
let idx = map.xy_idx(x, end_y - (y+1));
pattern.push(map.tiles[idx]);
}
}
patterns.push(pattern);
// Flip both
pattern = Vec::new();
for y in start_y .. end_y {
for x in start_x .. end_x {
let idx = map.xy_idx(end_x - (x+1), end_y -
(y+1));
pattern.push(map.tiles[idx]);
}
}
patterns.push(pattern);
}
}
}
// Dedupe
if dedupe {
println!("Pre de-duplication, there are {} patterns",
patterns.len());
let set: HashSet<Vec<TileType>> = patterns.drain(..).collect(); //
dedup
patterns.extend(set.into_iter());
println!("There are {} patterns", patterns.len());
}
patterns
}
1. At the top, we're importing some items from elsewhere in the project: Map ,
TileType , and the built-in collection HashMap .
2. We declare our build_patterns function, with parameters for a reference to the
source map, the chunk_size to use (tile size), and �ags ( bool variables) for
include_flipping and dedupe . These indicate which features we'd like to use
when reading the source map. We're returning a vector , containing a series of
vector s of di�erent TileType s. The outer container holds each pattern. The
inner vector holds the TileType s that make up the pattern itself.
3. We determine how many chunks there are in each direction and store it in
chunks_x and chunks_y .
4. We create a new vector called patterns . This will hold the result of the
function; we don't declare it's type, because Rust is smart enough to see that
we're returning it at the end of the function - and can �gure out what type it is
for us.
5. We iterate every vertical chunk in the variable cy :
1. We iterate every horizontal chunk in the variable cx :
1. We make a new vector to hold this pattern.
2. We calculate start_x , end_x , start_y and end_y to hold the four
corner coordinates of this chunk - on the original map.
3. We iterate the pattern in y / x order (to match our map format), read
in the TileType of each map tile within the chunk, and add it to the
pattern.
4. We push the pattern to the patterns result vector.
5. If include_flipping is set to true (because we'd like to �ip our tiles,
making more tiles!):
1. Repeat iterating y / x in di�erent orders, giving 3 more tiles.
Each is added to the patterns result vector.
6. If dedupe is set, then we are "de-duplicating" the pattern bu�er. Basically,
removing any pattern that occurs more than once. This is good for a map with
lots of wasted space, if you don't want to make an equally sparse result map. We
de-duplicate by adding the patterns into a HashMap (which can only store one of
each entry) and then reading it back out again.
For this to compile, we have to make TileType know how to convert itself into a hash.
HashMap uses "hashes" (basically a checksum of the contained values) to determine if
an entry is unique, and to help �nd it. In map.rs , we can simply add one more
derived attribute to the TileType enumeration:
This code should get you every 7x7 tile within your source �le - but it'd be great to be
able to prove that it works! As Reagan's speech-writer once wrote, Trust - But Verify. In
constraints.rs , we'll add another function: render_pattern_to_map :
This is pretty simple: iterate the pattern, and copy to a location on the map - o�set by
the start_x and start_y coordinates. Note that we're also marking the tile as
visible - this will make the renderer display our tiles in color.
Now we just need to display our tiles as part of the snapshot system. In
waveform_collapse/mod.rs add a new function as part of the implementation of
WaveformCollapseBuilder (underneath build ). It's a member function because it
needs access to the take_snapshot command:
x += chunk_size + 1;
if x + chunk_size > self.map.width {
// Move to the next row
x = 1;
y += chunk_size + 1;
x = 1;
y = 1;
}
}
counter += 1;
}
self.take_snapshot();
}
Also, comment out some code so that it doesn't crash from not being able to �nd a
starting point:
If you cargo run now, it'll show you the tile patterns from map sample 2:
Notice how �ipping has given us multiple variants of each tile. If we change the image
loading code to load wfc-demo1 (by changing the loader to
self.map = load_rex_map(self.depth,
&rltk::rex::XpFile::from_resource("../../resources/wfc-
demo1.xp").unwrap());
), we get chunks of our hand-drawn map:
use super::TileType;
if n_exits == 0 {
new_chunk.has_exits = false;
}
constraints.push(new_chunk);
}
}
if !has_any {
// There's no exits on this side, we don't care
what goes there
for compat in c.compatible_with.iter_mut() {
compat.push(j);
}
}
}
}
}
}
constraints
}
This is a really big function, but clearly broken down into sections. Let's take the time to
walk through what it actually does:
set both to false . We'll use these in the next steps. it_fits
means that there are one or more matching exits between c 's
exit tiles and potential 's entry tiles. has_any means that c
has any exits at all in this direction. We distinguish between the
two because if there are no exits in that direction, we don't care
what the neighbor is - we can't a�ect it. If there are exits, then
we only want to be compatible with tiles you can actually visit.
3. We iterate c 's exits, keeping both a slot (the tile number we
are evaluating) and the value of the exit tile ( can_enter ).
You'll remember that we've set these to true if they are a �oor
- and false otherwise - so we're iterating possible exits.
1. If can_enter is true , then we set has_any to true - it has
an exit in that direction.
2. We check potential_exits.exits[opposite][slot] - that
is that matching exit on the other tile, in the opposite
direction to the way we're going. If there is a match-up,
then you can go from tile c to tile potential in our
current direction ! That lets us set it_fits to true.
4. If it_fits is true , then there is a compatibility between the
tiles: we add j to c 's compatible_with vector for the current
direction.
5. If has_any is false , then we don't care about adjacency in this
direction - so we add j to the compatibility matrix for all
directions, just like we did for a tile with no exits.
7. Finally, we return our constraints results vector.
That's quite a complicated algorithm, so we don't really want to trust that I got it right.
We'll verify exit detection by adjusting our tile gallery code to show exits. In build ,
tweak the rendering order and what we're passing to render_tile_gallery :
x += chunk_size + 1;
if x + chunk_size > self.map.width {
// Move to the next row
x = 1;
y += chunk_size + 1;
x = 1;
y = 1;
}
}
counter += 1;
}
self.take_snapshot();
}
Now that we have the demo framework running, we can cargo run the project - and
see the tiles from wfc-demo2.xp correctly highlighting the exits:
self.map = Map::new(self.depth);
loop {
let mut solver = Solver::new(constraints.clone(), CHUNK_SIZE,
&self.map);
while !solver.iteration(&mut self.map, &mut rng) {
self.take_snapshot();
}
self.take_snapshot();
if solver.possible { break; } // If it has hit an impossible
condition, try again
}
We make a freshly solid map (since we've been using it for rendering tile demos, and
don't want to pollute the �nal map with a demo gallery!). Then we loop (the Rust
loop that runs forever until something calls break ). Inside that loop, we create a
solver for a copy of the constraints matrix (we copy it in case we have to go through
repeatedly; otherwise, we'd have to move it in and move it out again). We repeatedly
call the solver's iteration function, taking a snapshot each time - until it reports that
it is done. If the solver gave up and said it wasn't possible, we try again.
It stores the constraints we've been building, the chunk_size we're using, the
chunks we're resolving (more on that in a second), the number of chunks it can �t
onto the target map ( chunks_x , and chunks_y ), a remaining vector (more on that,
too), and a possible indicator to indicate whether or not it gave up.
chunks is a vector of Option<usize> . The usize value is the index of the chunk. It's
an option because we may not have �lled it in, yet - so it might be None or
Some(usize) . This nicely represents the "quantum waveform collapse" nature of the
problem - it either exists or it doesn't, and we don't know until we look at it!
remaining is a vector of all of the chunks, with their index. It's a tuple - we store the
chunk index in the �rst entry, and the number of existing neighbors in the second.
We'll use that to help decide which chunk to �ll in next, and remove it from the
remaining list when we've added one.
We'll need to implement methods for Solver , too. new is a basic constructor:
impl Solver {
pub fn new(constraints: Vec<MapChunk>, chunk_size: i32, map : &Map) ->
Solver {
let chunks_x = (map.width / chunk_size) as usize;
let chunks_y = (map.height / chunk_size) as usize;
let mut remaining : Vec<(usize, i32)> = Vec::new();
for i in 0..(chunks_x*chunks_y) {
remaining.push((i, 0));
}
Solver {
constraints,
chunk_size,
chunks: vec![None; chunks_x * chunks_y],
chunks_x,
chunks_y,
remaining,
possible: true
}
}
...
It calculates the size (for chunks_x and chunks_y ), �lls remaining with every tile and
no neighbors, and chunks with None values. This sets us up for our solving run! We
also need a helper function called chunk_idx :
if chunk_x > 0 {
let left_idx = self.chunk_idx(chunk_x-1, chunk_y);
match self.chunks[left_idx] {
None => {}
Some(_) => {
neighbors += 1;
}
}
}
if chunk_y > 0 {
let up_idx = self.chunk_idx(chunk_x, chunk_y-1);
match self.chunks[up_idx] {
None => {}
Some(_) => {
neighbors += 1;
}
}
}
This function could be a lot smaller, but I've left it spelling out every step for clarity. It
looks at a chunk, and determines if it has a created (not set to None ) chunk to the
North, South, East and West.
Finally, we get to the iteration function - which does the hard work:
// Pick a random chunk we haven't dealt with yet and get its index,
remove from remaining list
let remaining_index = if !neighbors_exist {
(rng.roll_dice(1, self.remaining.len() as i32)-1) as usize
} else {
0usize
};
let chunk_index = self.remaining[remaining_index].0;
self.remaining.remove(remaining_index);
if chunk_x > 0 {
let left_idx = self.chunk_idx(chunk_x-1, chunk_y);
match self.chunks[left_idx] {
None => {}
Some(nt) => {
neighbors += 1;
options.push(self.constraints[nt].compatible_with[3].clone());
}
}
}
options.push(self.constraints[nt].compatible_with[2].clone());
}
}
}
if chunk_y > 0 {
let up_idx = self.chunk_idx(chunk_x, chunk_y-1);
match self.chunks[up_idx] {
None => {}
Some(nt) => {
neighbors += 1;
options.push(self.constraints[nt].compatible_with[1].clone());
}
}
}
options.push(self.constraints[nt].compatible_with[0].clone());
}
}
}
if neighbors == 0 {
// There is nothing nearby, so we can have anything!
let new_chunk_idx = (rng.roll_dice(1, self.constraints.len() as
i32)-1) as usize;
self.chunks[chunk_index] = Some(new_chunk_idx);
let left_x = chunk_x as i32 * self.chunk_size as i32;
let right_x = (chunk_x as i32+1) * self.chunk_size as i32;
let top_y = chunk_y as i32 * self.chunk_size as i32;
let bottom_y = (chunk_y as i32+1) * self.chunk_size as i32;
if possible_options.is_empty() {
println!("Oh no! It's not possible!");
self.possible = false;
return true;
} else {
let new_chunk_idx = if possible_options.len() == 1 { 0 }
else { rng.roll_dice(1, possible_options.len() as i32)-1
};
false
}
This is another really big function, but once again that's because I tried to keep it easy
to read. Let's walk through the algorithm:
1. If there is nothing left in remaining , we return that we have completed the map.
possible is true, because we actually �nished the problem.
2. We take a clone of remaining to avoid borrow checker issues.
3. We iterate our copy of remaining , and for each remaining chunk:
1. We determine it's x and y location from the chunk index.
2. We call count_neighbors to determine how many (if any) neighboring
chunks have been resolved.
3. If any neighbors were found, we set neighbors_exist to true - telling the
algorithm that it has run at least once.
4. We update the copy of the remaining list to include the same index as
before, and the new neighbor count.
4. We sort our copy of remaining by the number of neighbors, descending - so the
chunk with the most neighbors is �rst.
5. We copy our clone of remaining back to our actual remaining list.
6. We want to create a new variable, remaining_index - to indicate which chunk
we're going to work on, and where it is in the remaining vector. If we haven't
made any tiles yet, we pick our starting point at random. Otherwise, we pick the
�rst entry in the remaining list - which will be the one with the most neighbors.
7. We obtain chunk_idx from the remaining list at the selected index, and
remove that chunk from the list.
8. Now we calculate chunk_x and chunk_y to tell us where it is on the new map.
9. We set a mutable variable, neighbors to 0; we'll be counting neighbors again.
10. We create a mutable variable called Options . It has the rather strange type
Vec<Vec<usize>> - it is a vector of vectors, each of which contains an array
index ( usize ). We'll be storing compatible options for each direction in here - so
we need the outer vector for directions, and the inner vector for options. These
index the constraints vector.
11. If it isn't the left-most chunk on the map, it may have a chunk to the west - so we
calculate the index of that chunk. If a chunk to the west exists (isn't None ), then
we add it's east bound compatible_with list to our Options vector. We
increment neighbors to indicate that we found a neighbor.
12. We repeat for the east - if it isn't the right-most chunk on the map. We increment
neighbors to indicate that we found a neighbor.
13. We repeat for the south - if it isn't the bottom chunk on the map. We increment
neighbors to indicate that we found a neighbor.
14. We repeat for the north - if it isn't the top chunk on the map. We increment
neighbors to indicate that we found a neighbor.
15. If there are no neighbors, we:
1. Find a random tile from constraints .
2. Figure out the bounds of where we are placing the tile in left_x , right_x
, top_y , and bottom_y .
3. Copy the selected tile to the map.
16. If there are neighbors, we:
1. Insert all of the options from each direction into a HashSet . We used
HashSet to de-duplicate our tiles earlier, and this is what we're doing here:
we're removing all duplicate options, so we don't evaluate them
repeatedly.
2. We make a new vector called possible_options . For each option in the
HashSet :
1. Set a mutable variable called possible to true .
2. Check each directions' options , and if it is compatible with its
neighbors preferences - add it to possible_options .
3. If possible_options is empty - then we've hit a brick wall, and can't add
any more tiles. We set possible to false in the parent structure and bail
out!
4. Otherwise, we pick a random entry from possible_options and draw it to
the map.
So while it's a long function, it isn't a really complicated one. It looks for possible
combinations for each iteration, and tries to apply them - giving up and returning
failure if it can't �nd one.
The caller is already taking snapshots of each iteration, so if we cargo run the project
with our wfc-test1.xp �le we get something like this:
Not the greatest map, but you can watch the solver chug along - placing tiles one at a
time. Now lets try it with wfc-test2.xmp , a set of tiles designed for tiling:
This is kind-of fun - it lays it out like a jigsaw, and eventually gets a map! The map isn't
as well connected as one might hope, the edges with no exit lead to a smaller play
area (which is culled at the end). It's still a good start!
That's a much more interesting map! You can try it with wfc-test2.xp as well:
Once again, it's an interesting and playable map! The problem is that we've got such a
small chunk size that there really aren't all that many interesting options for adjacency
- 3x3 grids really limits the amount of variability you can have on your map! So we'll
try wfc-test1.xp with a chunk size of 5:
That's more like it! It's not dissimilar from a map we might try and generate in another
fashion.
Notice that we're removing down stairs - the Cellular Automata generator will place
one, and we don't want stairs everywhere! This gives a very pleasing result:
more speci�c that we will see some failures, but lets give it a go anyway. In our code
that builds a compatibility matrix, �nd the comment
There's no exits on this side and replace the section with this code:
if !has_any {
// There's no exits on this side, let's match only if
// the other edge also has no exits
let matching_exit_count = potential.exits[opposite].iter().filter(|a|
!**a).count();
if matching_exit_count == 0 {
c.compatible_with[direction].push(j);
}
}
Run against the our cellular automata example, we see a bit of a change:
Overall, that change is a winner! It doesn't look very good with our jigsaw puzzle
anymore; there just aren't enough tiles to make good patterns.
impl WaveformCollapseBuilder {
pub fn new(new_depth : i32, mode : WaveformMode, derive_from :
Option<Box<dyn MapBuilder>>) -> WaveformCollapseBuilder {
WaveformCollapseBuilder{
map : Map::new(new_depth),
starting_position : Position{ x: 0, y : 0 },
depth : new_depth,
history: Vec::new(),
noise_areas : HashMap::new(),
mode,
derive_from
}
}
Then we'll add some functionality into the top of our build function:
fn build(&mut self) {
if self.mode == WaveformMode::TestMap {
self.map = load_rex_map(self.depth,
&rltk::rex::XpFile::from_resource("../../resources/wfc-
demo1.xp").unwrap());
self.take_snapshot();
return;
}
Now we'll add a couple of constructors to make it easier for random_builder to not
have to know about the innards of the WFC algorithm:
if rng.roll_dice(1, 3)==1 {
result = Box::new(WaveformCollapseBuilder::derived_map(new_depth,
result));
}
result
}
That's quite a change. We roll a 17-sided dice (wouldn't it be nice if those really
existed?), and pick a builder - as before, but with the option to use the .xp �le from
There are quite a few warnings in the project when you compile. They are almost all
"this function is never used" (or equivalent). Since we're building a library of map
builders, it's ok to not always call the constructors. You can add an annotation above a
function de�nition - #[allow(dead_code)] to tell the compiler to stop worrying about
this. For example, in drunkard.rs :
impl DrunkardsWalkBuilder {
#[allow(dead_code)]
pub fn new(new_depth : i32, settings: DrunkardSettings) ->
DrunkardsWalkBuilder {
I've gone through and applied these where necessary in the example code to silence
the compiler.
use rltk::{rex::XpFile};
rltk::embedded_resource!(SMALL_DUNGEON, "../../resources
/SmallDungeon_80x50.xp");
rltk::embedded_resource!(WFC_DEMO_IMAGE1, "../../resources/wfc-demo1.xp");
impl RexAssets {
#[allow(clippy::new_without_default)]
pub fn new() -> RexAssets {
rltk::link_resource!(SMALL_DUNGEON, "../../resources
/SmallDungeon_80x50.xp");
rltk::link_resource!(WFC_DEMO_IMAGE1, "../../resources/wfc-
demo1.xp");
RexAssets{
menu : XpFile::from_resource("../../resources
/SmallDungeon_80x50.xp").unwrap()
}
}
}
This saves a little bit of space in the resulting binary (never a bad thing: smaller
binaries �t into your CPU's cache better, and generally run faster).
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.
Despite being essentially pseudorandom (that is, random - but constrained in a way
that makes for a fun, cohesive game), many roguelikes feature some hand-crafted
content. Typically, these can be divided into a few categories:
Hand-crafted levels - the whole level is premade, the content static. These are
typically used very sparingly, for big set-piece battles essential to the story.
Hand-crafted level sections - some of the level is randomly created, but a large
part is pre-made. For example, a fortress might be a "set piece", but the
dungeon leading up to it is random. Dungeon Crawl Stone Soup uses these
extensively - you sometimes run into areas that you recognize because they are
prefabricated - but the dungeon around them is clearly random. Cogmind uses
these for parts of the caves (I'll avoid spoilers). Caves of Qud has a few set-piece
levels that appear to be built around a number of prefabricated parts. Some
systems call this mechanism "vaults" - but the name can also apply to the third
category.
Hand-crafted rooms (also called Vaults in some cases). The level is largely
random, but when sometimes a room �ts a vault - so you put one there.
The �rst category is special and should be used sparingly (otherwise, your players will
just learn an optimal strategy and power on through it - and may become bored from
lack of variety). The other categories bene�t from either providing lots of vaults (so
there's a ton of content to sprinkle around, meaning the game doesn't feel too similar
each time you play) or being rare - so you only occasionally see them (for the same
reason).
Some Clean Up
In the Waveform Collapse chapter, we loaded a pre-made level - without any entities
(those are added later). It's not really very nice to hide a map loader inside WFC - since
that isn't it's primary purpose - so we'll start by removing it:
impl WaveformCollapseBuilder {
/// Generic constructor for waveform collapse.
/// # Arguments
/// * new_depth - the new map depth
/// * derive_from - either None, or a boxed MapBuilder, as output by
`random_builder`
pub fn new(new_depth : i32, derive_from : Option<Box<dyn MapBuilder>>)
-> WaveformCollapseBuilder {
WaveformCollapseBuilder{
map : Map::new(new_depth),
starting_position : Position{ x: 0, y : 0 },
depth : new_depth,
history: Vec::new(),
noise_areas : HashMap::new(),
derive_from
}
}
We've removed all references to image_loader , removed the test map constructor,
and removed the ugly mode enumeration. WFC is now exactly what it says on the tin,
and nothing else. Lastly, we'll modify random_builder to not use the test map
anymore:
if rng.roll_dice(1, 3)==1 {
result = Box::new(WaveformCollapseBuilder::derived_map(new_depth,
result));
}
result
}
Skeletal Builder
We'll start with a very basic skeleton, similar to those used before. We'll make a new
�le, prefab_builder.rs in map_builders :
fn build_map(&mut self) {
self.build();
}
fn take_snapshot(&mut self) {
if SHOW_MAPGEN_VISUALIZER {
let mut snapshot = self.map.clone();
for v in snapshot.revealed_tiles.iter_mut() {
*v = true;
}
self.history.push(snapshot);
}
}
}
impl PrefabBuilder {
fn build(&mut self) {
}
}
#[derive(PartialEq, Clone)]
#[allow(dead_code)]
pub enum PrefabMode {
RexLevel{ template : &'static str }
}
This is new - an enum with variables? This works because under the hood, Rust
enumerations are actually unions. They can hold whatever you want to put in there,
and the type is sized to hold the largest of the options. It's best used sparingly in tight
code, but for things like con�guration it is a very clean way to pass in data. We should
also update the constructor to create the new types:
impl PrefabBuilder {
#[allow(dead_code)]
pub fn new(new_depth : i32) -> PrefabBuilder {
PrefabBuilder{
map : Map::new(new_depth),
starting_position : Position{ x: 0, y : 0 },
depth : new_depth,
history : Vec::new(),
mode : PrefabMode::RexLevel{ template : "../../resources/wfc-
demo1.xp" }
}
}
...
Including the map template path in the mode makes for easier reading, even if it is
slightly more complicated. We're not �lling the PrefabBuilder with variables for all of
the options we might use - we're keeping them separated. That's generally good
practice - it makes it much more obvious to someone who reads your code what's
going on.
#[allow(dead_code)]
fn load_rex_map(&mut self, path: &str) {
let xp_file = rltk::rex::XpFile::from_resource(path).unwrap();
That's pretty straightforward, more or less a direct port of the one form the Waveform
Collapse chapter. Now lets start making our build function:
fn build(&mut self) {
match self.mode {
PrefabMode::RexLevel{template} => self.load_rex_map(&template)
}
// Find a starting point; start at the middle and walk left until we
find an open tile
self.starting_position = Position{ x: self.map.width / 2, y :
self.map.height / 2 };
let mut start_idx = self.map.xy_idx(self.starting_position.x,
self.starting_position.y);
while self.map.tiles[start_idx] != TileType::Floor {
self.starting_position.x -= 1;
start_idx = self.map.xy_idx(self.starting_position.x,
self.starting_position.y);
}
self.take_snapshot();
}
Notice that we've copied over the �nd starting point code; we'll improve that at some
point, but for now it ensures you can play your level. We haven't spawned anything -
so you will be alone in the level. There's also a slightly di�erent usage of match here -
we're using the variable in the enum. The code PrefabMode::RexLevel{template}
says "match RexLevel , but with any value of template - and make that value
available via the name template in the match scope". You could use _ to match any
value if you didn't want to access it. Rust's pattern matching system is really
impressive - you can do a lot with it!
Lets modify our random_builder function to always call this type of map (so we don't
have to test over and over in the hopes of getting the one we want!). In
map_builders/mod.rs :
if rng.roll_dice(1, 3)==1 {
result = Box::new(WaveformCollapseBuilder::derived_map(new_depth,
result));
}
result*/
Box::new(PrefabBuilder::new(new_depth))
}
If you cargo run your project now, you can run around the (otherwise deserted)
demo map:
The color coding is completely optional, but I put it in for clarity. You'll see we have an
@ to indicate the player start, a > to indicate the exit, and a bunch of g goblins, o
orcs, ! potions, % rations and ^ traps. Not too bad a map, really.
use rltk::{rex::XpFile};
rltk::embedded_resource!(SMALL_DUNGEON, "../../resources
/SmallDungeon_80x50.xp");
rltk::embedded_resource!(WFC_DEMO_IMAGE1, "../../resources/wfc-demo1.xp");
rltk::embedded_resource!(WFC_POPULATED, "../../resources/wfc-
populated.xp");
impl RexAssets {
#[allow(clippy::new_without_default)]
pub fn new() -> RexAssets {
rltk::link_resource!(SMALL_DUNGEON, "../../resources
/SmallDungeon_80x50.xp");
rltk::link_resource!(WFC_DEMO_IMAGE1, "../../resources/wfc-
demo1.xp");
rltk::link_resource!(WFC_POPULATED, "../../resources/wfc-
populated.xp");
RexAssets{
menu : XpFile::from_resource("../../resources
/SmallDungeon_80x50.xp").unwrap()
}
}
}
We also want to be able to list out spawns that are required by the map. Looking in
spawner.rs , we have an established tuple format for how we pass spawns - so we'll
use it in the struct:
#[allow(dead_code)]
pub struct PrefabBuilder {
map : Map,
starting_position : Position,
depth: i32,
history: Vec<Map>,
mode: PrefabMode,
spawns: Vec<(usize, String)>
}
Now we'll modify our constructor to use the new map, and initialize spawns :
impl PrefabBuilder {
#[allow(dead_code)]
pub fn new(new_depth : i32) -> PrefabBuilder {
PrefabBuilder{
map : Map::new(new_depth),
starting_position : Position{ x: 0, y : 0 },
depth : new_depth,
history : Vec::new(),
mode : PrefabMode::RexLevel{ template : "../../resources/wfc-
populated.xp" },
spawns: Vec::new()
}
}
...
To make use of the function in spawner.rs that accepts this type of data, we need to
make it public. So we open up the �le, and add the word pub to the function
signature:
We'll then modify our PrefabBuilder 's spawn_entities function to make use of this
data:
We do a bit of a dance with references just to work with the previous function
signature (and not have to change it, which would change lots of other code). So far,
so good - it reads the spawn list, and requests that everything in the list be placed
onto the map. Now would be a good time to add something to the list! We'll want to
modify our load_rex_map to handle the new data:
#[allow(dead_code)]
fn load_rex_map(&mut self, path: &str) {
let xp_file = rltk::rex::XpFile::from_resource(path).unwrap();
_ => {
println!("Unknown glyph loading map: {}",
(cell.ch as u8) as char);
}
}
}
}
}
}
}
This recognizes the extra glyphs, and prints a warning to the console if we've loaded
one we forgot to handle. Note that for entities, we're setting the tile to Floor and
then adding the entity type. That's because we can't overlay two glyphs on the same
tile - but it stands to reason that the entity is standing on a �oor.
Lastly, we need to modify our build function to not move the exit and the player. We
simply wrap the fallback code in an if statement to detect if we've set a
starting_position (we're going to require that if you set a start, you also set an exit):
fn build(&mut self) {
match self.mode {
PrefabMode::RexLevel{template} => self.load_rex_map(&template)
}
self.take_snapshot();
// Find a starting point; start at the middle and walk left until we
find an open tile
if self.starting_position.x == 0 {
self.starting_position = Position{ x: self.map.width / 2, y :
self.map.height / 2 };
let mut start_idx = self.map.xy_idx(self.starting_position.x,
self.starting_position.y);
while self.map.tiles[start_idx] != TileType::Floor {
self.starting_position.x -= 1;
start_idx = self.map.xy_idx(self.starting_position.x,
self.starting_position.y);
}
self.take_snapshot();
If you cargo run the project now, you start in the speci�ed location - and entities
spawn around you.
Rex-free prefabs
It's possible that you don't like Rex Paint (don't worry, I won't tell Kyzrati!), maybe you
are on a platform that doesn't support it - or maybe you'd just like to not have to rely
on an external tool. We'll extend our reader to also support string output for maps.
This will be handy later when we get to small room prefabs/vaults.
I cheated a bit, and opened the wfc-populated.xp �le in Rex and typed ctrl-t to
save in TXT format. That gave me a nice Notepad friendly map �le:
I also realized that prefab_builder was going to outgrow a single �le! Fortunately,
Rust makes it pretty easy to turn a module into a multi-�le monster. In map_builders ,
Make a new �le in your prefab_builder folder, and name it prefab_levels.rs . We'll
paste in the map de�nition, and decorate it a bit:
So we start by de�ning a new struct type: PrefabLevel . This holds a map template,
a width and a height. Then we make a constant, WFC_POPULATED and create an always-
available level de�nition in it. Lastly, we paste our Notepad �le into a new constant,
currently called MY_LEVEL . This is a big string, and will be stored like any other string.
We'll modify our build function to also handle this match pattern:
fn build(&mut self) {
match self.mode {
PrefabMode::RexLevel{template} => self.load_rex_map(&template),
PrefabMode::Constant{level} => self.load_ascii_map(&level)
}
self.take_snapshot();
...
impl PrefabBuilder {
#[allow(dead_code)]
pub fn new(new_depth : i32) -> PrefabBuilder {
PrefabBuilder{
map : Map::new(new_depth),
starting_position : Position{ x: 0, y : 0 },
depth : new_depth,
history : Vec::new(),
mode : PrefabMode::Constant{level :
prefab_levels::WFC_POPULATED},
spawns: Vec::new()
}
}
Now we need to create a loader that can handle it. We'll modify our load_rex_map to
share some code with it, so we aren't typing everything repeatedly - and make our
new load_ascii_map function:
#[allow(dead_code)]
fn load_rex_map(&mut self, path: &str) {
let xp_file = rltk::rex::XpFile::from_resource(path).unwrap();
#[allow(dead_code)]
fn load_ascii_map(&mut self, level: &prefab_levels::PrefabLevel) {
// Start by converting to a vector, with newlines removed
let mut string_vec : Vec<char> = level.template.chars().filter(|a| *a
!= '\r' && *a !='\n').collect();
for c in string_vec.iter_mut() { if *c as u8 == 160u8 { *c = ' '; } }
let mut i = 0;
for ty in 0..level.height {
for tx in 0..level.width {
if tx < self.map.width as usize && ty < self.map.height as
usize {
let idx = self.map.xy_idx(tx as i32, ty as i32);
self.char_to_map(string_vec[i], idx);
}
i += 1;
}
}
}
The �rst thing to notice is that the giant match in load_rex_map is now a function -
char_to_map . Since we're using the functionality more than once, this is good
practice: now we only have to �x it once if we messed it up! Otherwise, load_rex_map
is pretty much the same. Our new function is load_ascii_map . It starts with some
ugly code that bears explanation:
If you cargo run now, you'll see exactly the same as before - but instead of loading
the Rex Paint �le, we've loaded it from the constant ASCII in prefab_levels.rs .
We'll extend our mapping system to explicitly support this: a regular builder makes a
map, and then a sectional prefab replaces part of the map with your exciting premade
content. We'll start by making a new �le (in map_builders/prefab_builder ) called
prefab_sections.rs , and place a description of what we want:
#[allow(dead_code)]
#[derive(PartialEq, Copy, Clone)]
pub enum HorizontalPlacement { Left, Center, Right }
#[allow(dead_code)]
#[derive(PartialEq, Copy, Clone)]
pub enum VerticalPlacement { Top, Center, Bottom }
#[allow(dead_code)]
#[derive(PartialEq, Copy, Clone)]
pub struct PrefabSection {
pub template : &'static str,
pub width : usize,
pub height: usize,
pub placement : (HorizontalPlacement, VerticalPlacement)
}
#[allow(dead_code)]
pub const UNDERGROUND_FORT : PrefabSection = PrefabSection{
template : RIGHT_FORT,
width: 15,
height: 43,
placement: ( HorizontalPlacement::Right, VerticalPlacement::Top )
};
#[allow(dead_code)]
const RIGHT_FORT : &str = "
#
#######
# #
# #######
# g #
# #######
# #
### ###
# #
# #
# ##
^
^
# ##
# #
# #
# #
# #
### ###
# #
# #
# g #
# #
# #
### ###
# #
# #
# #
# ##
^
^
# ##
# #
# #
# #
### ###
# #
# #######
# g #
# #######
# #
#######
#
";
Level sections are di�erent from builders we've made before, because they take a
completed map - and replace part of it. We've done something similar with Waveform
Collapse, so we'll adopt a similar pattern. We'll start by modifying our PrefabBuilder
to know about the new type of map decoration:
#[allow(dead_code)]
pub struct PrefabBuilder {
map : Map,
starting_position : Position,
depth: i32,
history: Vec<Map>,
mode: PrefabMode,
spawns: Vec<(usize, String)>,
previous_builder : Option<Box<dyn MapBuilder>>
}
As much as I'd love to put the previous_builder into the enum, I kept running into
lifetime problems. Perhaps there's a way to do it (and some kind reader will help me
out?), but for now I've put it into PrefabBuilder . The requested map section is in the
parameter, however. We also update our constructor to use this type of map:
impl PrefabBuilder {
#[allow(dead_code)]
pub fn new(new_depth : i32, previous_builder : Option<Box<dyn
MapBuilder>>) -> PrefabBuilder {
PrefabBuilder{
map : Map::new(new_depth),
starting_position : Position{ x: 0, y : 0 },
depth : new_depth,
history : Vec::new(),
mode : PrefabMode::Sectional{ section:
prefab_sections::UNDERGROUND_FORT },
spawns: Vec::new(),
previous_builder
}
}
...
Over in map_builders/mod.rs 's random_builder , we'll modify the builder to �rst run
Box::new(
PrefabBuilder::new(
new_depth,
Some(
Box::new(
CellularAutomotaBuilder::new(new_depth)
)
)
)
)
This could be one line, but I've separated it out due to the sheer number of
parentheses.
Next, we update our match statement (in build() ) to actually call the builder:
fn build(&mut self) {
match self.mode {
PrefabMode::RexLevel{template} => self.load_rex_map(&template),
PrefabMode::Constant{level} => self.load_ascii_map(&level),
PrefabMode::Sectional{section} => self.apply_sectional(§ion)
}
self.take_snapshot();
...
use prefab_sections::*;
let chunk_y;
match section.placement.1 {
VerticalPlacement::Top => chunk_y = 0,
VerticalPlacement::Center => chunk_y = (self.map.height / 2) -
(section.height as i32 / 2),
VerticalPlacement::Bottom => chunk_y = (self.map.height-1) -
section.height as i32
}
println!("{},{}", chunk_x, chunk_y);
let mut i = 0;
for ty in 0..section.height {
for tx in 0..section.width {
if tx < self.map.width as usize && ty < self.map.height as
usize {
let idx = self.map.xy_idx(tx as i32 + chunk_x, ty as i32 +
chunk_y);
self.char_to_map(string_vec[i], idx);
}
i += 1;
}
}
self.take_snapshot();
}
This a lot like other code we've written, but lets step through it anyway:
If you cargo run the example now, you'll see a map built with a cave - and a
forti�cation to the right.
You may also notice that there aren't any entities at all, outside of the prefab area!
Congratulations, half your source code just turned red in your IDE. That's the danger
of changing a base interface - you wind up implementing it everywhere. Also, the setup
of spawn_entities has changed - there is now a default implementation.
Implementers of the trait can override it if they want to - but otherwise they don't
actually need to write it anymore. Since everything should be available via the
get_spawn_list function, the trait has everything it needs to provide that
implementation.
We'll go back to simple_map and update it to obey the new trait rules. We'll extend
the SimpleMapBuiler structure to feature a spawn list:
Now for the fun part. Previously, we didn't consider spawning until the call to
spawn_entities . Lets remind ourselves what it does (it's been a while!):
It iterates all the rooms, and spawns entities inside the rooms. We're using that
pattern a lot, so it's time to visit spawn_room in spawner.rs . We'll modify it to spawn
into a spawn_list rather than directly onto the map. So we open up spawner.rs , and
modify spawn_room and spawn_region (since they are intertwined, we'll �x them
together):
for _i in 0 .. num_spawns {
let array_index = if areas.len() == 1 { 0usize } else {
(rng.roll_dice(1, areas.len() as i32)-1) as usize };
You'll notice that the biggest change is taking a mutable reference to the spawn_list
in each function, and instead of actually spawning the entity - we defer the operation
by pushing the spawn information into the spawn_list vector at the end. Instead of
passing in the ECS, we're passing in the Map and RandomNumberGenerator .
Going back to simple_map.rs , we move the spawning code into the end of build :
...
self.starting_position = Position{ x: start_pos.0, y: start_pos.1 };
The same changes can be made to all of the builders that rely on room spawning; for
brevity, I won't spell them all out here - you can �nd them in the source code. The
various builders that use Voronoi diagrams are similarly simple to update. For
example, Cellular Automata. Add the spawn_list to the builder structure, and add a
spawn_list : Vec::new() into the constructor. Move the monster spawning from
spawn_entities into the end of build and delete the function. Copy the
get_spawn_list from the other implementations. We changed the region spawning
code a little, so here's the implementation from cellular_automota.rs :
Once again, it's rinse and repeat on the other Voronoi spawn algorithms. I've done the
work in the source code for you, if you'd like to take a peek.
SO - now that we've refactored our spawn system, how do we use it inside our
PrefabBuilder ? We can add one line to our apply_sectional function and get all of
the entities from the previous map. You could simply copy it, but that's probably not
what you want; you need to �lter out entities inside the new prefab, both to make
room for new ones and to ensure that the spawning makes sense. We'll also need to
rearrange a little to keep the borrow checker happy. Here's the function now:
let chunk_y;
match section.placement.1 {
VerticalPlacement::Top => chunk_y = 0,
VerticalPlacement::Center => chunk_y = (self.map.height / 2) -
(section.height as i32 / 2),
VerticalPlacement::Bottom => chunk_y = (self.map.height-1) -
section.height as i32
}
let mut i = 0;
for ty in 0..section.height {
for tx in 0..section.width {
if tx > 0 && tx < self.map.width as usize -1 && ty <
self.map.height as usize -1 && ty > 0 {
let idx = self.map.xy_idx(tx as i32 + chunk_x, ty as i32 +
chunk_y);
self.char_to_map(string_vec[i], idx);
}
i += 1;
}
}
self.take_snapshot();
}
If you cargo run now, you'll face enemies in both sections of the map.
Wrap Up
In this chapter, we've covered quite a bit of ground:
We can load Rex Paint levels, complete with hand-placed entities and play them.
We can de�ne ASCII premade maps in our game, and play them (removing the
requirement to use Rex Paint).
We can load level sectionals, and apply them to the level.
We can adjust the spawns from previous levels in the builder chain.
...
Room Vaults
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.
The last chapter was getting overly long, so it was broken into two. In the previous
chapter, we learned how to load prefabricated maps and map sections, modi�ed the
spawn system so that meta-builders could a�ect the spawn patterns from the previous
builder, and demonstrated integration of whole map chunks into levels. In this
chapter, we'll explode room vaults - prefabricated content that integrates itself into
your level. So you might hand-craft some rooms, and have them seamlessly �t into
your existing map.
The life of a roguelike developer is part programmer, part interior decorator (in a
weirdly Gnome Mad Scientist fashion). We've already designed whole levels and level
sections, so it isn't a huge leap to designing rooms. Lets go ahead and build a few pre-
designed rooms.
#[allow(dead_code)]
#[derive(PartialEq, Copy, Clone)]
pub struct PrefabRoom {
pub template : &'static str,
pub width : usize,
pub height: usize,
pub first_depth: i32,
pub last_depth: i32
}
#[allow(dead_code)]
pub const TOTALLY_NOT_A_TRAP : PrefabRoom = PrefabRoom{
template : TOTALLY_NOT_A_TRAP_MAP,
width: 5,
height: 5,
first_depth: 0,
last_depth: 100
};
#[allow(dead_code)]
const TOTALLY_NOT_A_TRAP_MAP : &str = "
^^^
^!^
^^^
";
If you look at the ASCII, you'll see a classic piece of map design: a health potion
completely surrounded by traps. Since the traps are hidden by default, we're relying
on the player to think "well, that doesn't look suspicious at all"! Not that there are
spaces all around the content - there's a 1-tile gutter all around it. This ensures that
any 5x5 room into which the vault is placed will still be traversable. We're also
introducing first_depth and last_depth - these are the levels at which the vault
might be applied; for the sake of introduction, we'll pick 0..100 - which should be every
We're not going to add any parameters yet - by the end of the chapter, we'll have it
integrated into a broader system for placing vaults. We'll update our constructor to
use this type of placement:
impl PrefabBuilder {
#[allow(dead_code)]
pub fn new(new_depth : i32, previous_builder : Option<Box<dyn
MapBuilder>>) -> PrefabBuilder {
PrefabBuilder{
map : Map::new(new_depth),
starting_position : Position{ x: 0, y : 0 },
depth : new_depth,
history : Vec::new(),
mode : PrefabMode::RoomVaults,
previous_builder,
spawn_list : Vec::new()
}
}
...
fn build(&mut self) {
match self.mode {
PrefabMode::RexLevel{template} => self.load_rex_map(&template),
PrefabMode::Constant{level} => self.load_ascii_map(&level),
PrefabMode::Sectional{section} => self.apply_sectional(§ion),
PrefabMode::RoomVaults => self.apply_room_vaults()
}
self.take_snapshot();
...
That leaves the next logical step being to write apply_room_vaults . Our objective is
to scan the incoming map (from a di�erent builder, even a previous iteration of this
one!) for appropriate places into which we can place a vault, and add it to the map.
We'll also want to remove any spawned creatures from the vault area - so the vaults
remain hand-crafted and aren't interfered with by random spawning.
We'll be re-using our "create previous iteration" code from apply_sectional - so lets
rewrite it into a more generic form:
That sounds really complicated, but most of what it as done is allow us to replace the
following code in apply_sectional :
Room Vaults
Let's start building apply_room_vaults . We'll take it step-by-step, and work our way
fn apply_room_vaults(&mut self) {
use prefab_rooms::*;
let mut rng = RandomNumberGenerator::new();
// Apply the previous builder, and keep all entities it spawns (for now)
self.apply_previous_iteration(|_x,_y,_e| true);
We use the code we just wrote to apply the previous map. The filter we're passing
in this time always returns true: keep all the entities for now. Next:
// Note that this is a place-holder and will be moved out of this function
let master_vault_list = vec![TOTALLY_NOT_A_TRAP];
// Filter the vault list down to ones that are applicable to the current
depth
let possible_vaults : Vec<&PrefabRoom> = master_vault_list
.iter()
.filter(|v| { self.depth >= v.first_depth && self.depth <=
v.last_depth })
.collect();
We make a vector of all possible vault types - there's currently only one, but when we
have more they go in here. This isn't really ideal, but we'll worry about making it a
global resource in a future chapter. We then make a possible_vaults list by taking
the master_vault_list and �ltering it to only include those whose first_depth and
Next up:
if possible {
vault_positions.push(Position{ x,y });
break;
}
idx += 1;
if idx >= self.map.tiles.len()-1 { break; }
}
There's quite a bit of code in this section (which determines all the places a the vault
might �t). Lets walk through it:
1. We make a new vector of Position s. This will contain all the possible places in
which we could spawn our vault.
2. We set idx to 0 - we plan to iterate through the whole map.
3. We start a loop - the Rust loop type that doesn't exit until you call break .
In other words, we quickly scan the whole map for everywhere we could put the vault -
and make a list of possible placements. We then:
if !vault_positions.is_empty() {
let pos_idx = if vault_positions.len()==1 { 0 } else {
(rng.roll_dice(1, vault_positions.len() as i32)-1) as usize };
let pos = &vault_positions[pos_idx];
1. Pick a random entry in the vault_positions vector - this is where we will place
the vault.
2. Use read_ascii_to_vec to read in the ASCII, just like we did in prefabs and
sectionals.
3. Iterate the vault data and use char_to_map to place it - just like we did before.
fn apply_room_vaults(&mut self) {
use prefab_rooms::*;
let mut rng = RandomNumberGenerator::new();
// Apply the previous builder, and keep all entities it spawns (for
now)
self.apply_previous_iteration(|_x,_y,_e| true);
// Filter the vault list down to ones that are applicable to the
current depth
let possible_vaults : Vec<&PrefabRoom> = master_vault_list
.iter()
.filter(|v| { self.depth >= v.first_depth && self.depth <=
v.last_depth })
.collect();
if possible {
vault_positions.push(Position{ x,y });
break;
}
idx += 1;
if idx >= self.map.tiles.len()-1 { break; }
}
if !vault_positions.is_empty() {
let pos_idx = if vault_positions.len()==1 { 0 } else {
(rng.roll_dice(1, vault_positions.len() as i32)-1) as usize };
let pos = &vault_positions[pos_idx];
It's more likely that a square vault will �t in rectangular rooms, so we'll pop over to
map_builders/mod.rs and slightly adjust the random_builder to use the original
simple map algorithm for the base map:
Box::new(
PrefabBuilder::new(
new_depth,
Some(
Box::new(
SimpleMapBuilder::new(new_depth)
)
)
)
)
If you cargo run now, the vault will probably be placed on your map. Here's a
screenshot of a run in which I found it:
We probably don't want to keep entities that are inside our new vault from the
previous map iteration. You might have a cunningly placed trap and spawn a goblin
on top of it! (While fun, probably not what you had in mind). So we'll extend
apply_room_vaults to do some �ltering when it places the vault. We want to �lter
before we spawn new stu�, and then spawn more stu� with the room. Enter the
retain feature:
...
let chunk_y = pos.y;
Calling retain on a vector iterates through every entry, and calls the passed
closure/lambda function. If it returns true , then the element is retained (kept) -
otherwise it is removed. So here we're catching width and height (to avoid
borrowing self ), and then calculate the location for each entry. If it is outside of the
new vault - we keep it.
#[allow(dead_code)]
#[derive(PartialEq, Copy, Clone)]
pub struct PrefabRoom {
pub template : &'static str,
pub width : usize,
pub height: usize,
pub first_depth: i32,
pub last_depth: i32
}
#[allow(dead_code)]
pub const TOTALLY_NOT_A_TRAP : PrefabRoom = PrefabRoom{
template : TOTALLY_NOT_A_TRAP_MAP,
width: 5,
height: 5,
first_depth: 0,
last_depth: 100
};
#[allow(dead_code)]
const TOTALLY_NOT_A_TRAP_MAP : &str = "
^^^
^!^
^^^
";
#[allow(dead_code)]
pub const SILLY_SMILE : PrefabRoom = PrefabRoom{
template : SILLY_SMILE_MAP,
width: 6,
height: 6,
first_depth: 0,
last_depth: 100
};
#[allow(dead_code)]
const SILLY_SMILE_MAP : &str = "
^ ^
#
###
";
#[allow(dead_code)]
pub const CHECKERBOARD : PrefabRoom = PrefabRoom{
template : CHECKERBOARD_MAP,
width: 6,
height: 6,
first_depth: 0,
last_depth: 100
};
#[allow(dead_code)]
const CHECKERBOARD_MAP : &str = "
g#%#
^# #
";
We've added CHECKERBOARD (a grid of walls and spaces with traps, a goblin and
goodies in it), and SILLY_SMILE which just looks like a silly wall feature. Now open up
apply_room_vaults in map_builders/prefab_builder/mod.rs and add these to the
master vector:
// Note that this is a place-holder and will be moved out of this function
let master_vault_list = vec![TOTALLY_NOT_A_TRAP, CHECKERBOARD,
SILLY_SMILE];
If you cargo run now, you'll most likely encounter one of the three vaults. Each time
you advance a depth, you will probably encounter one of the three. My test ran into
the checkerboard almost immediately:
That's a great start, and gives a bit of �air to maps as you descend - but it may not be
quite what you were asking for when you said you wanted more than one vault! How
about more than one vault on a level? Back to apply_room_vaults ! It's easy enough to
come up with a number of vaults to spawn:
This sets n_vaults to the minimum value of a dice roll ( 1d3 ) and the number of
possible vaults - so it'll never exceed the number of options, but can vary a bit. It's also
pretty easy to wrap the creation function in a for loop:
for _i in 0..n_vaults {
...
self.take_snapshot();
possible_vaults.remove(vault_index);
}
}
Notice that at the end of the loop, we're removing the vault we added from
possible_vaults . We have to change the declaration to be able to do that:
let mut possible_vaults : Vec<&PrefabRoom> = ... - we add the mut to allow us
to change the vector. This way, we won't keep adding the same vault - they only get
spawned once.
Now for the more di�cult part: making sure that our new vaults don't overlap the
previously spawned ones. We'll create a new HashSet of tiles we've consumed:
Hash sets have the advantage of o�ering a quick way to say if they contain a value, so
they are ideal for what we need. We'll insert the tile idx into the set when we add a
tile:
for ty in 0..vault.height {
for tx in 0..vault.width {
let idx = self.map.xy_idx(tx as i32 + chunk_x, ty as i32 +
chunk_y);
self.char_to_map(string_vec[i], idx);
used_tiles.insert(idx);
i += 1;
}
}
Now if you cargo run your project, you might encounter several vaults. Here's a case
where we encountered two vaults:
// Apply the previous builder, and keep all entities it spawns (for now)
self.apply_previous_iteration(|_x,_y,_e| true);
This is very simple: we roll a six-sided dice and add the current depth. If we rolled less
than 4 , we bail out and just provide the previously generated map. If you cargo run
your project now, you'll sometimes encounter vaults - and sometimes you won't.
#[allow(dead_code)]
pub fn rex_level(new_depth : i32, template : &'static str) ->
PrefabBuilder {
PrefabBuilder{
map : Map::new(new_depth),
starting_position : Position{ x: 0, y : 0 },
depth : new_depth,
history : Vec::new(),
mode : PrefabMode::RexLevel{ template },
previous_builder : None,
spawn_list : Vec::new()
}
}
#[allow(dead_code)]
pub fn constant(new_depth : i32, level : prefab_levels::PrefabLevel) ->
PrefabBuilder {
PrefabBuilder{
map : Map::new(new_depth),
starting_position : Position{ x: 0, y : 0 },
depth : new_depth,
history : Vec::new(),
mode : PrefabMode::Constant{ level },
previous_builder : None,
spawn_list : Vec::new()
}
}
#[allow(dead_code)]
pub fn sectional(new_depth : i32, section :
prefab_sections::PrefabSection, previous_builder : Box<dyn MapBuilder>) ->
PrefabBuilder {
PrefabBuilder{
map : Map::new(new_depth),
starting_position : Position{ x: 0, y : 0 },
depth : new_depth,
history : Vec::new(),
mode : PrefabMode::Sectional{ section },
previous_builder : Some(previous_builder),
spawn_list : Vec::new()
}
}
#[allow(dead_code)]
pub fn vaults(new_depth : i32, previous_builder : Box<dyn MapBuilder>) ->
PrefabBuilder {
PrefabBuilder{
map : Map::new(new_depth),
starting_position : Position{ x: 0, y : 0 },
depth : new_depth,
history : Vec::new(),
mode : PrefabMode::RoomVaults,
previous_builder : Some(previous_builder),
spawn_list : Vec::new()
}
}
The syntax for this is currently quite ugly (that will be a future chapter topic). In
map_builders/mod.rs :
Box::new(
PrefabBuilder::vaults(
new_depth,
Box::new(PrefabBuilder::sectional(
new_depth,
prefab_builder::prefab_sections::UNDERGROUND_FORT,
Box::new(WaveformCollapseBuilder::derived_map(
new_depth,
Box::new(CellularAutomotaBuilder::new(new_depth))
))
))
)
)
If you cargo run this, you get to watch it cycle through the layered building:
Restoring Randomness
Now that we've completed a two-chapter marathon of prefabricated, layered map
building - it's time to restore the random_builder function to provide randomness
once more. Here's the new function from map_builders/mod.rs :
if rng.roll_dice(1, 3)==1 {
result = Box::new(WaveformCollapseBuilder::derived_map(new_depth,
result));
}
if rng.roll_dice(1, 20)==1 {
result = Box::new(PrefabBuilder::sectional(new_depth,
prefab_builder::prefab_sections::UNDERGROUND_FORT ,result));
}
result
}
We're taking full advantage of the composability of our layers system now! Our
random builder now:
1. In the �rst layer, we roll 1d17 and pick a map type; we've included our pre-made
level as one of the options.
2. Next, we roll 1d3 - and on a 1, we run the WaveformCollapse algorithm on that
builder.
3. We roll 1d20 , and on a 1 - we apply a PrefabBuilder sectional, and add our
fortress. That way, you'll only occasionally run into it.
4. We run whatever builder we came up with against our PrefabBuilder 's Room
Vault system (the focus of this chapter!), to add premade rooms to the mix.
Wrap-Up
In this chapter, we've gained the ability to prefabricate rooms and include them if they
�t into our level design. We've also explored the ability to add algorithms together,
giving even more layers of randomness.
...
Layering/Builder Chaining
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.
A builder-based interface
Builder chaining is a pretty profound approach to procedurally generating maps, and
gives us an opportunity to clean up a lot of the code we've built thus far. We want an
interface similar to the way we build entities with Specs : a builder, onto which we can
keep chaining builders and return it as an "executor" - ready to build the maps. We
also want to stop builders from doing more than one thing - they should do one thing,
and do it well (that's a good principle of design; it makes debugging easier, and
reduces duplication).
There are two major types of builders: those that just make a map (and only make
sense to run once), and those that modify an existing map. We'll name those
InitialMapBuilder and MetaMapBuilder respectively.
It makes sense then that the builder should have a start_with method that accepts
the �rst map, and additional with methods to chain builders. The builders should be
stored in a container that preserves the order in which they were added - a vector
being the obvious choice.
It would also make sense to no longer make individual builders responsible for setting
up their predecessors; ideally, a builder shouldn't have to know anything about the
process beyond what it does. So we need to abstract the process, and support
snapshotting (so you can view your procedural generation progress) along the way.
You'll notice that this has all of the data we've been building into each map builder -
and nothing else. It's intentionally generic - we'll be passing it to builders, and letting
them work on it. Notice that all the �elds are public - that's because we're passing it
around, and there's a good chance that anything that touches it will need to access
any/all of its contents.
The BuilderMap also needs to facilitate the task of taking snapshots for debugger
viewing of maps as we work on algorithms. We're going to put one function into
BuilderMap - to handle snapshotting development:
impl BuilderMap {
fn take_snapshot(&mut self) {
if SHOW_MAPGEN_VISUALIZER {
let mut snapshot = self.map.clone();
for v in snapshot.revealed_tiles.iter_mut() {
*v = true;
}
self.history.push(snapshot);
}
}
}
This is the same as the take_snapshot code we've been mixing into our builders.
Since we're using a central repository of map building knowledge, we can promote it
to apply to all our builders.
starter is an Option , so we know if there is one. Not having a �rst step (a map
that doesn't refer to other maps) would be an error condition, so we'll track it.
We're referencing a new trait, InitialMapBuilder ; we'll get to that in a moment.
builders is a vector of MetaMapBuilders , another new trait (and again - we'll
get to it in a moment). These are builders that operate on the results of previous
maps.
build_data is a public variable (anyone can read/write it), containing the
BuilderMap we just made.
impl BuilderChain {
pub fn new(new_depth : i32) -> BuilderChain {
BuilderChain{
starter: None,
builders: Vec::new(),
build_data : BuilderMap {
spawn_list: Vec::new(),
map: Map::new(new_depth),
starting_position: None,
rooms: None,
history : Vec::new()
}
}
}
...
This is pretty simple: it makes a new BuilderChain with default values for everything.
Now, lets permit our users to add a starting map to the chain. (A starting map is a �rst
step that doesn't require a previous map as input, and results in a usable map
structure which we may then modify):
...
pub fn start_with(&mut self, starter : Box<dyn InitialMapBuilder>) {
match self.starter {
None => self.starter = Some(starter),
Some(_) => panic!("You can only have one starting builder.")
};
}
...
There's one new concept in here: panic! . If the user tries to add a second starting
builder, we'll crash - because that doesn't make any sense. You'd simply be
overwriting your previous steps, which is a giant waste of time! We'll also permit the
user to add meta-builders:
...
pub fn with(&mut self, metabuilder : Box<dyn MetaMapBuilder>) {
self.builders.push(metabuilder);
}
...
This is very simple: we simply add the meta-builder to the builder vector. Since vectors
remain in the order in which you add to them, your operations will remain sorted
appropriately. Finally, we'll implement a function to actually construct the map:
1. We match on our starting map. If there isn't one, we panic - and crash the
program with a message that you have to set a starting builder.
2. We call build_map on the starting map.
3. For each meta-builder, we call build_map on it - in the order speci�ed.
That's not a bad syntax! It should enable us to chain builders together, and provide
the required overview for constructing complicated, layered maps.
Lets look at the two trait interfaces we've de�ned, InitialMapBuilder and
MetaMapBuilder . We made them separate types to force the user to only pick one
starting builder, and not try to put any starting builders in the list of modi�cation
layers. The implementation for them is the same:
Spawn Function
We'll also want to implement our spawning system:
This is almost exactly the same code as our previous spawner in MapBuilder , but
instead we're spawning from the spawn_list in our build_data structure.
Otherwise, it's identical.
Finally, we'll modify random_builder to use our SimpleMapBuilder with some new
types to break out the creation steps:
Modifying SimpleMapBuilder
We can simplify SimpleMapBuilder (making it worthy of the name!) quite a bit. Here's
the new code:
impl SimpleMapBuilder {
#[allow(dead_code)]
pub fn new() -> Box<SimpleMapBuilder> {
Box::new(SimpleMapBuilder{})
}
for _i in 0..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);
let mut 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();
if !rooms.is_empty() {
let (new_x, new_y) = new_room.center();
let (prev_x, prev_y) = rooms[rooms.len()-1].center();
if rng.range(0,1) == 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);
}
}
rooms.push(new_room);
build_data.take_snapshot();
}
}
build_data.rooms = Some(rooms);
}
}
This is basically the same as the old SimpleMapBuilder , but there's a number of
changes:
Room-based spawning
Create a new �le, room_based_spawner.rs in the map_builders directory. We're
going to apply just the room populating system from the old SimpleMapBuilder here:
impl RoomBasedSpawner {
#[allow(dead_code)]
pub fn new() -> Box<RoomBasedSpawner> {
Box::new(RoomBasedSpawner{})
}
We've reduced the functionality to just one task: if there are rooms, we spawn
monsters in them.
impl RoomBasedStartingPosition {
#[allow(dead_code)]
pub fn new() -> Box<RoomBasedStartingPosition> {
Box::new(RoomBasedStartingPosition{})
}
Room-based stairs
This is also very similar to how we generated exit stairs in SimpleMapBuilder . Make a
impl RoomBasedStairs {
#[allow(dead_code)]
pub fn new() -> Box<RoomBasedStairs> {
Box::new(RoomBasedStairs{})
}
Now that we've made all of the steps, this should make sense:
If you cargo run the project now, you'll let lots of warnings about unused code - but
the game should play with just the simple map from our �rst section. You may be
wondering why we've taken so much e�ort to keep things the same; hopefully, it will
become clear as we clean up more builders!
impl BspDungeonBuilder {
#[allow(dead_code)]
pub fn new() -> Box<BspDungeonBuilder> {
Box::new(BspDungeonBuilder{
rects: Vec::new(),
})
}
if self.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;
}
result
}
can_build
}
}
Just like SimpleMapBuilder , we've stripped out all the non-room building code for a
much cleaner piece of code. We're referencing the build_data struct from the
builder, rather than making our own copies of everything - and the meat of the code is
largely the same.
If you cargo run now, you'll get a dungeon based on the BspDungeonBuilder . See
how you are reusing the spawner, starting position and stairs code? That's de�nitely
an improvement over the older versions - if you change one, it can now help on
multiple builders!
impl BspInteriorBuilder {
#[allow(dead_code)]
pub fn new() -> Box<BspInteriorBuilder> {
Box::new(BspInteriorBuilder{
rects: Vec::new()
})
}
build_data.map.tiles[idx] = TileType::Floor;
}
}
}
build_data.take_snapshot();
}
build_data.rooms = Some(rooms);
}
// Calculate boundaries
let width = rect.x2 - rect.x1;
let height = rect.y2 - rect.y1;
let half_width = width / 2;
let half_height = height / 2;
if split <= 2 {
// Horizontal split
let h1 = Rect::new( rect.x1, rect.y1, half_width-1, height );
self.rects.push( h1 );
Cellular Automata
You should understand the basic idea here, now - we're breaking up builders into
small chunks, and implementing the appropriate traits for the map type. Looking at
Cellular Automata maps, you'll see that we do things a little di�erently:
We place the exit far from the starting position. That's also a di�erent algorithm
step.
The good news is that the last three of those are used in lots of other builders - so
implementing them will let us reuse the code, and not keep repeating ourselves. The
bad news is that if we run our cellular automata builder with the existing room-based
steps, it will crash - we don't have rooms!
So we'll start by constructing the basic map builder. Like the others, this is mostly just
rearranging code to �t with the new trait scheme. Here's the new
cellular_automota.rs �le:
impl CellularAutomotaBuilder {
#[allow(dead_code)]
pub fn new() -> Box<CellularAutomotaBuilder> {
Box::new(CellularAutomotaBuilder{})
}
#[allow(clippy::map_entry)]
fn build(&mut self, rng : &mut RandomNumberGenerator, build_data :
&mut BuilderMap) {
// First we completely randomize the map, setting 55% of it to be
floor.
for y in 1..build_data.map.height-1 {
for x in 1..build_data.map.width-1 {
let roll = rng.roll_dice(1, 100);
let idx = build_data.map.xy_idx(x, y);
if roll > 55 { build_data.map.tiles[idx] = TileType::Floor
}
else { build_data.map.tiles[idx] = TileType::Wall }
}
}
build_data.take_snapshot();
for y in 1..build_data.map.height-1 {
for x in 1..build_data.map.width-1 {
let idx = build_data.map.xy_idx(x, y);
let mut neighbors = 0;
if build_data.map.tiles[idx - 1] == TileType::Wall {
neighbors += 1; }
if build_data.map.tiles[idx + 1] == TileType::Wall {
neighbors += 1; }
if build_data.map.tiles[idx - build_data.map.width as
usize] == TileType::Wall { neighbors += 1; }
if build_data.map.tiles[idx + build_data.map.width as
usize] == TileType::Wall { neighbors += 1; }
if build_data.map.tiles[idx - (build_data.map.width as
usize - 1)] == TileType::Wall { neighbors += 1; }
if build_data.map.tiles[idx - (build_data.map.width as
usize + 1)] == TileType::Wall { neighbors += 1; }
if build_data.map.tiles[idx + (build_data.map.width as
usize - 1)] == TileType::Wall { neighbors += 1; }
if build_data.map.tiles[idx + (build_data.map.width as
usize + 1)] == TileType::Wall { neighbors += 1; }
build_data.map.tiles = newtiles.clone();
build_data.take_snapshot();
}
}
}
It's entirely possible that we don't actually want to start in the middle of the map.
Doing so presents lots of opportunities (and helps ensure connectivity), but maybe
you would rather the player trudge through lots of map with less opportunity to pick
the wrong direction. Maybe your story makes more sense if the player arrives at one
end of the map and leaves via another. Lets implement a starting position system that
takes a preferred starting point, and picks the closest valid tile. Create
area_starting_points.rs :
#[allow(dead_code)]
pub enum XStart { LEFT, CENTER, RIGHT }
#[allow(dead_code)]
pub enum YStart { TOP, CENTER, BOTTOM }
impl AreaStartingPosition {
#[allow(dead_code)]
pub fn new(x : XStart, y : YStart) -> Box<AreaStartingPosition> {
Box::new(AreaStartingPosition{
x, y
})
}
match self.x {
XStart::LEFT => seed_x = 1,
XStart::CENTER => seed_x = build_data.map.width / 2,
XStart::RIGHT => seed_x = build_data.map.width - 2
}
match self.y {
YStart::TOP => seed_y = 1,
YStart::CENTER => seed_y = build_data.map.height / 2,
YStart::BOTTOM => seed_y = build_data.map.height - 2
}
available_floors.sort_by(|a,b| a.1.partial_cmp(&b.1).unwrap());
We've covered the boilerplate enough to not need to go over it again - so lets step
through the build function:
1. We are taking in a couple of enum types: preferred position on the X and Y axes.
2. So we set seed_x and seed_y to a point closest to the speci�ed locations.
3. We iterate through the whole map, adding �oor tiles to available_floors - and
calculating the distance to the preferred starting point.
4. We sort the available tile list, so the lower distances are �rst.
5. We pick the �rst one on the list.
The great part here is that this will work for any map type - it searches for �oors to
stand on, and tries to �nd the closest starting point.
We've previously had good luck with culling areas that can't be reached from the
starting point. So lets formalize that into its own meta-builder. Create
cull_unreachable.rs :
impl CullUnreachable {
#[allow(dead_code)]
pub fn new() -> Box<CullUnreachable> {
Box::new(CullUnreachable{})
}
returning a Dijkstra map. That's the intent: we remove areas the player can't get to,
and only do that.
Voronoi-based spawning
impl VoronoiSpawning {
#[allow(dead_code)]
pub fn new() -> Box<VoronoiSpawning> {
Box::new(VoronoiSpawning{})
}
#[allow(clippy::map_entry)]
fn build(&mut self, rng : &mut RandomNumberGenerator, build_data :
&mut BuilderMap) {
let mut noise_areas : HashMap<i32, Vec<usize>> = HashMap::new();
let mut noise = rltk::FastNoise::seeded(rng.roll_dice(1, 65536) as
u64);
noise.set_noise_type(rltk::NoiseType::Cellular);
noise.set_frequency(0.08);
noise.set_cellular_distance_function(rltk::CellularDistanceFunction::Manhattan);
for y in 1 .. build_data.map.height-1 {
for x in 1 .. build_data.map.width-1 {
let idx = build_data.map.xy_idx(x, y);
if build_data.map.tiles[idx] == TileType::Floor {
let cell_value_f = noise.get_noise(x as f32, y as f32)
* 10240.0;
let cell_value = cell_value_f as i32;
if noise_areas.contains_key(&cell_value) {
noise_areas.get_mut(&cell_value).unwrap().push(idx);
} else {
noise_areas.insert(cell_value, vec![idx]);
}
}
}
}
This is almost exactly the same as the code from common.rs we were calling in
various builders, just modi�ed to work within the builder chaining/builder map
framework.
Another commonly used piece of code generated a Dijkstra map of the level, starting
at the player's entry point - and used that map to place the exit at the most distant
location from the player. This was in common.rs , and we called it a lot. We'll turn this
into a map building step; create map_builders/distant_exit.rs :
impl DistantExit {
#[allow(dead_code)]
pub fn new() -> Box<DistantExit> {
Box::new(DistantExit{})
}
// Place a staircase
let stairs_idx = exit_tile.0;
build_data.map.tiles[stairs_idx] = TileType::DownStairs;
build_data.take_snapshot();
}
}
Again, this is the same code we've used previously - just tweaked to match the new
interface, so we won't go over it in detail.
We've �nally got all the pieces together, so lets give it a test. In random_builder , we'll
use the new builder chains:
If you cargo run now, you'll get to play in a Cellular Automata generated map.
impl DrunkardsWalkBuilder {
#[allow(dead_code)]
pub fn new(settings: DrunkardSettings) -> DrunkardsWalkBuilder {
DrunkardsWalkBuilder{
settings
}
}
#[allow(dead_code)]
pub fn open_area() -> Box<DrunkardsWalkBuilder> {
Box::new(DrunkardsWalkBuilder{
settings : DrunkardSettings{
spawn_mode: DrunkSpawnMode::StartingPoint,
drunken_lifetime: 400,
floor_percent: 0.5,
brush_size: 1,
symmetry: Symmetry::None
}
})
}
#[allow(dead_code)]
pub fn open_halls() -> Box<DrunkardsWalkBuilder> {
Box::new(DrunkardsWalkBuilder{
settings : DrunkardSettings{
spawn_mode: DrunkSpawnMode::Random,
drunken_lifetime: 400,
floor_percent: 0.5,
brush_size: 1,
symmetry: Symmetry::None
},
})
}
#[allow(dead_code)]
pub fn winding_passages() -> Box<DrunkardsWalkBuilder> {
Box::new(DrunkardsWalkBuilder{
settings : DrunkardSettings{
spawn_mode: DrunkSpawnMode::Random,
drunken_lifetime: 100,
floor_percent: 0.4,
brush_size: 1,
symmetry: Symmetry::None
},
})
}
#[allow(dead_code)]
pub fn fat_passages() -> Box<DrunkardsWalkBuilder> {
Box::new(DrunkardsWalkBuilder{
settings : DrunkardSettings{
spawn_mode: DrunkSpawnMode::Random,
drunken_lifetime: 100,
floor_percent: 0.4,
brush_size: 2,
symmetry: Symmetry::None
},
})
}
#[allow(dead_code)]
pub fn fearful_symmetry() -> Box<DrunkardsWalkBuilder> {
Box::new(DrunkardsWalkBuilder{
settings : DrunkardSettings{
spawn_mode: DrunkSpawnMode::Random,
drunken_lifetime: 100,
floor_percent: 0.4,
brush_size: 1,
symmetry: Symmetry::Both
},
})
}
drunk_life -= 1;
}
if did_something {
build_data.take_snapshot();
}
digger_count += 1;
for t in build_data.map.tiles.iter_mut() {
if *t == TileType::DownStairs {
*t = TileType::Floor;
}
}
floor_tile_count = build_data.map.tiles.iter().filter(|a| **a
== TileType::Floor).count();
}
}
}
impl DLABuilder {
#[allow(dead_code)]
pub fn new() -> Box<DLABuilder> {
Box::new(DLABuilder{
algorithm: DLAAlgorithm::WalkInwards,
brush_size: 2,
symmetry: Symmetry::None,
floor_percent: 0.25,
})
}
#[allow(dead_code)]
pub fn walk_inwards() -> Box<DLABuilder> {
Box::new(DLABuilder{
algorithm: DLAAlgorithm::WalkInwards,
brush_size: 1,
symmetry: Symmetry::None,
floor_percent: 0.25,
})
}
#[allow(dead_code)]
pub fn walk_outwards() -> Box<DLABuilder> {
Box::new(DLABuilder{
algorithm: DLAAlgorithm::WalkOutwards,
brush_size: 2,
symmetry: Symmetry::None,
floor_percent: 0.25,
})
}
#[allow(dead_code)]
pub fn central_attractor() -> Box<DLABuilder> {
Box::new(DLABuilder{
algorithm: DLAAlgorithm::CentralAttractor,
brush_size: 2,
symmetry: Symmetry::None,
floor_percent: 0.25,
})
}
#[allow(dead_code)]
pub fn insectoid() -> Box<DLABuilder> {
Box::new(DLABuilder{
algorithm: DLAAlgorithm::CentralAttractor,
brush_size: 2,
symmetry: Symmetry::Horizontal,
floor_percent: 0.25,
})
}
#[allow(clippy::map_entry)]
fn build(&mut self, rng : &mut RandomNumberGenerator, build_data :
&mut BuilderMap) {
// Carve a starting seed
let starting_position = Position{ x: build_data.map.width/2, y :
build_data.map.height/2 };
let start_idx = build_data.map.xy_idx(starting_position.x,
starting_position.y);
build_data.take_snapshot();
build_data.map.tiles[start_idx] = TileType::Floor;
build_data.map.tiles[start_idx-1] = TileType::Floor;
build_data.map.tiles[start_idx+1] = TileType::Floor;
build_data.map.tiles[start_idx-build_data.map.width as usize] =
TileType::Floor;
build_data.map.tiles[start_idx+build_data.map.width as usize] =
TileType::Floor;
// Random walker
let total_tiles = build_data.map.width * build_data.map.height;
let desired_floor_tiles = (self.floor_percent * total_tiles as
f32) as usize;
let mut floor_tile_count = build_data.map.tiles.iter().filter(|a|
**a == TileType::Floor).count();
while floor_tile_count < desired_floor_tiles {
match self.algorithm {
DLAAlgorithm::WalkInwards => {
let mut digger_x = rng.roll_dice(1,
build_data.map.width - 3) + 1;
let mut digger_y = rng.roll_dice(1,
build_data.map.height - 3) + 1;
let mut prev_x = digger_x;
let mut prev_y = digger_y;
let mut digger_idx = build_data.map.xy_idx(digger_x,
digger_y);
while build_data.map.tiles[digger_idx] ==
TileType::Wall {
prev_x = digger_x;
prev_y = digger_y;
let stagger_direction = rng.roll_dice(1, 4);
match stagger_direction {
1 => { if digger_x > 2 { digger_x -= 1; } }
2 => { if digger_x < build_data.map.width-2 {
digger_x += 1; } }
3 => { if digger_y > 2 { digger_y -=1; } }
_ => { if digger_y < build_data.map.height-2 {
digger_y += 1; } }
}
digger_idx = build_data.map.xy_idx(digger_x,
digger_y);
}
paint(&mut build_data.map, self.symmetry,
self.brush_size, prev_x, prev_y);
}
DLAAlgorithm::WalkOutwards => {
let mut digger_x = starting_position.x;
let mut digger_y = starting_position.y;
let mut digger_idx = build_data.map.xy_idx(digger_x,
digger_y);
while build_data.map.tiles[digger_idx] ==
TileType::Floor {
DLAAlgorithm::CentralAttractor => {
let mut digger_x = rng.roll_dice(1,
build_data.map.width - 3) + 1;
let mut digger_y = rng.roll_dice(1,
build_data.map.height - 3) + 1;
let mut prev_x = digger_x;
let mut prev_y = digger_y;
let mut digger_idx = build_data.map.xy_idx(digger_x,
digger_y);
while build_data.map.tiles[digger_idx] ==
TileType::Wall && !path.is_empty() {
prev_x = digger_x;
prev_y = digger_y;
digger_x = path[0].x;
digger_y = path[0].y;
path.remove(0);
digger_idx = build_data.map.xy_idx(digger_x,
digger_y);
}
paint(&mut build_data.map, self.symmetry,
self.brush_size, prev_x, prev_y);
}
build_data.take_snapshot();
impl MazeBuilder {
#[allow(dead_code)]
pub fn new() -> Box<MazeBuilder> {
Box::new(MazeBuilder{})
}
#[allow(clippy::map_entry)]
fn build(&mut self, rng : &mut RandomNumberGenerator, build_data :
&mut BuilderMap) {
// Maze gen
let mut maze = Grid::new((build_data.map.width / 2)-2,
(build_data.map.height / 2)-2, rng);
maze.generate_maze(build_data);
}
}
#[derive(Copy, Clone)]
struct Cell {
row: i32,
column: i32,
walls: [bool; 4],
visited: bool,
}
impl Cell {
fn new(row: i32, column: i32) -> Cell {
Cell{
row,
column,
walls: [true, true, true, true],
visited: false
}
}
if x == 1 {
self.walls[LEFT] = false;
(*(next)).walls[RIGHT] = false;
}
else if x == -1 {
self.walls[RIGHT] = false;
(*(next)).walls[LEFT] = false;
}
else if y == 1 {
self.walls[TOP] = false;
(*(next)).walls[BOTTOM] = false;
}
else if y == -1 {
self.walls[BOTTOM] = false;
(*(next)).walls[TOP] = false;
}
}
}
struct Grid<'a> {
width: i32,
height: i32,
cells: Vec<Cell>,
backtrace: Vec<usize>,
current: usize,
rng : &'a mut RandomNumberGenerator
}
impl<'a> Grid<'a> {
fn new(width: i32, height:i32, rng: &mut RandomNumberGenerator) ->
Grid {
let mut grid = Grid{
width,
height,
cells: Vec::new(),
backtrace: Vec::new(),
current: 0,
rng
};
grid
}
for i in neighbor_indices.iter() {
if *i != -1 && !self.cells[*i as usize].visited {
neighbors.push(*i as usize);
}
}
neighbors
match next {
Some(next) => {
self.cells[next].visited = true;
self.backtrace.insert(0, self.current);
unsafe {
let next_cell : *mut Cell = &mut self.cells[next];
let current_cell = &mut self.cells[self.current];
current_cell.remove_walls(next_cell);
}
self.current = next;
}
None => {
if !self.backtrace.is_empty() {
self.current = self.backtrace[0];
self.backtrace.remove(0);
} else {
break;
}
}
}
if i % 50 == 0 {
self.copy_to_map(&mut build_data.map);
build_data.take_snapshot();
}
i += 1;
}
}
map.tiles[idx] = TileType::Floor;
if !cell.walls[TOP] { map.tiles[idx - map.width as usize] =
TileType::Floor }
if !cell.walls[RIGHT] { map.tiles[idx + 1] = TileType::Floor }
if !cell.walls[BOTTOM] { map.tiles[idx + map.width as usize] =
TileType::Floor }
if !cell.walls[LEFT] { map.tiles[idx - 1] = TileType::Floor }
}
}
}
impl VoronoiCellBuilder {
#[allow(dead_code)]
pub fn new() -> Box<VoronoiCellBuilder> {
Box::new(VoronoiCellBuilder{
n_seeds: 64,
distance_algorithm: DistanceAlgorithm::Pythagoras,
})
}
#[allow(dead_code)]
pub fn pythagoras() -> Box<VoronoiCellBuilder> {
Box::new(VoronoiCellBuilder{
n_seeds: 64,
distance_algorithm: DistanceAlgorithm::Pythagoras,
})
}
#[allow(dead_code)]
pub fn manhattan() -> Box<VoronoiCellBuilder> {
Box::new(VoronoiCellBuilder{
n_seeds: 64,
distance_algorithm: DistanceAlgorithm::Manhattan,
})
}
#[allow(clippy::map_entry)]
fn build(&mut self, rng : &mut RandomNumberGenerator, build_data :
&mut BuilderMap) {
// Make a Voronoi diagram. We'll do this the hard way to learn
about the technique!
let mut voronoi_seeds : Vec<(usize, rltk::Point)> = Vec::new();
pos.1
);
}
}
voroni_distance[seed] = (seed, distance);
}
voroni_distance.sort_by(|a,b| a.1.partial_cmp(&b.1).unwrap());
for y in 1..build_data.map.height-1 {
for x in 1..build_data.map.width-1 {
let mut neighbors = 0;
let my_idx = build_data.map.xy_idx(x, y);
let my_seed = voronoi_membership[my_idx];
if voronoi_membership[build_data.map.xy_idx(x-1, y)] !=
my_seed { neighbors += 1; }
if voronoi_membership[build_data.map.xy_idx(x+1, y)] !=
my_seed { neighbors += 1; }
if voronoi_membership[build_data.map.xy_idx(x, y-1)] !=
my_seed { neighbors += 1; }
if voronoi_membership[build_data.map.xy_idx(x, y+1)] !=
my_seed { neighbors += 1; }
if neighbors < 2 {
build_data.map.tiles[my_idx] = TileType::Floor;
}
}
build_data.take_snapshot();
}
}
}
impl WaveformCollapseBuilder {
/// Generic constructor for waveform collapse.
/// # Arguments
/// * new_depth - the new map depth
/// * derive_from - either None, or a boxed MapBuilder, as output by
`random_builder`
#[allow(dead_code)]
pub fn new() -> Box<WaveformCollapseBuilder> {
Box::new(WaveformCollapseBuilder{})
}
build_data.map = Map::new(build_data.map.depth);
loop {
let mut solver = Solver::new(constraints.clone(), CHUNK_SIZE,
&build_data.map);
while !solver.iteration(&mut build_data.map, rng) {
build_data.take_snapshot();
}
build_data.take_snapshot();
if solver.possible { break; } // If it has hit an impossible
condition, try again
}
build_data.spawn_list.clear();
}
x += chunk_size + 1;
if x + chunk_size > build_data.map.width {
// Move to the next row
x = 1;
y += chunk_size + 1;
x = 1;
y = 1;
}
}
counter += 1;
}
build_data.take_snapshot();
}
}
TODO: Text!
Test:
#[allow(dead_code)]
pub struct PrefabBuilder {
mode: PrefabMode
}
impl PrefabBuilder {
#[allow(dead_code)]
pub fn new() -> Box<PrefabBuilder> {
Box::new(PrefabBuilder{
mode : PrefabMode::RoomVaults,
})
}
#[allow(dead_code)]
pub fn rex_level(template : &'static str) -> Box<PrefabBuilder> {
Box::new(PrefabBuilder{
mode : PrefabMode::RexLevel{ template },
})
}
#[allow(dead_code)]
pub fn constant(level : prefab_levels::PrefabLevel) ->
Box<PrefabBuilder> {
Box::new(PrefabBuilder{
mode : PrefabMode::Constant{ level },
})
}
#[allow(dead_code)]
pub fn sectional(section : prefab_sections::PrefabSection) ->
Box<PrefabBuilder> {
Box::new(PrefabBuilder{
mode : PrefabMode::Sectional{ section },
})
}
#[allow(dead_code)]
pub fn vaults() -> Box<PrefabBuilder> {
Box::new(PrefabBuilder{
mode : PrefabMode::RoomVaults,
})
}
BuilderMap) {
match ch {
' ' => build_data.map.tiles[idx] = TileType::Floor,
'#' => build_data.map.tiles[idx] = TileType::Wall,
'@' => {
let x = idx as i32 % build_data.map.width;
let y = idx as i32 / build_data.map.width;
build_data.map.tiles[idx] = TileType::Floor;
build_data.starting_position = Some(Position{ x:x as i32,
y:y as i32 });
}
'>' => build_data.map.tiles[idx] = TileType::DownStairs,
'g' => {
build_data.map.tiles[idx] = TileType::Floor;
build_data.spawn_list.push((idx, "Goblin".to_string()));
}
'o' => {
build_data.map.tiles[idx] = TileType::Floor;
build_data.spawn_list.push((idx, "Orc".to_string()));
}
'^' => {
build_data.map.tiles[idx] = TileType::Floor;
build_data.spawn_list.push((idx, "Bear
Trap".to_string()));
}
'%' => {
build_data.map.tiles[idx] = TileType::Floor;
build_data.spawn_list.push((idx, "Rations".to_string()));
}
'!' => {
build_data.map.tiles[idx] = TileType::Floor;
build_data.spawn_list.push((idx, "Health
Potion".to_string()));
}
_ => {
println!("Unknown glyph loading map: {}", (ch as u8) as
char);
}
}
}
#[allow(dead_code)]
fn load_rex_map(&mut self, path: &str, build_data : &mut BuilderMap) {
let xp_file = rltk::rex::XpFile::from_resource(path).unwrap();
for y in 0..layer.height {
for x in 0..layer.width {
let cell = layer.get(x, y).unwrap();
if x < build_data.map.width as usize && y <
build_data.map.height as usize {
let idx = build_data.map.xy_idx(x as i32, y as
i32);
// We're doing some nasty casting to make it
easier to type things like '#' in the match
self.char_to_map(cell.ch as u8 as char, idx,
build_data);
}
}
}
}
}
#[allow(dead_code)]
fn load_ascii_map(&mut self, level: &prefab_levels::PrefabLevel,
build_data : &mut BuilderMap) {
let string_vec = PrefabBuilder::read_ascii_to_vec(level.template);
let mut i = 0;
for ty in 0..level.height {
for tx in 0..level.width {
if tx < build_data.map.width as usize && ty <
build_data.map.height as usize {
let idx = build_data.map.xy_idx(tx as i32, ty as i32);
self.char_to_map(string_vec[i], idx, build_data);
}
i += 1;
}
}
}
{
let spawn_clone = build_data.spawn_list.clone();
for e in spawn_clone.iter() {
let idx = e.0;
let x = idx as i32 % build_data.map.width;
let y = idx as i32 / build_data.map.width;
if filter(x, y, e) {
build_data.spawn_list.push(
(idx, e.1.to_string())
)
}
}
build_data.take_snapshot();
}
#[allow(dead_code)]
fn apply_sectional(&mut self, section :
&prefab_sections::PrefabSection, rng: &mut RandomNumberGenerator,
build_data : &mut BuilderMap) {
use prefab_sections::*;
let string_vec =
PrefabBuilder::read_ascii_to_vec(section.template);
let chunk_y;
match section.placement.1 {
VerticalPlacement::Top => chunk_y = 0,
VerticalPlacement::Center => chunk_y = (build_data.map.height
/ 2) - (section.height as i32 / 2),
VerticalPlacement::Bottom => chunk_y =
(build_data.map.height-1) - section.height as i32
}
let mut i = 0;
for ty in 0..section.height {
for tx in 0..section.width {
if tx > 0 && tx < build_data.map.width as usize -1 && ty <
build_data.map.height as usize -1 && ty > 0 {
let idx = build_data.map.xy_idx(tx as i32 + chunk_x,
ty as i32 + chunk_y);
self.char_to_map(string_vec[i], idx, build_data);
}
i += 1;
}
}
build_data.take_snapshot();
}
// Filter the vault list down to ones that are applicable to the
current depth
let mut possible_vaults : Vec<&PrefabRoom> = master_vault_list
.iter()
.filter(|v| { build_data.map.depth >= v.first_depth &&
build_data.map.depth <= v.last_depth })
.collect();
for _i in 0..n_vaults {
if possible {
vault_positions.push(Position{ x,y });
break;
}
idx += 1;
if idx >= build_data.map.tiles.len()-1 { break; }
}
if !vault_positions.is_empty() {
let pos_idx = if vault_positions.len()==1 { 0 } else {
(rng.roll_dice(1, vault_positions.len() as i32)-1) as usize };
let pos = &vault_positions[pos_idx];
let string_vec =
PrefabBuilder::read_ascii_to_vec(vault.template);
let mut i = 0;
for ty in 0..vault.height {
for tx in 0..vault.width {
let idx = build_data.map.xy_idx(tx as i32 +
chunk_x, ty as i32 + chunk_y);
self.char_to_map(string_vec[i], idx, build_data);
used_tiles.insert(idx);
i += 1;
}
}
build_data.take_snapshot();
possible_vaults.remove(vault_index);
}
}
}
}
You can test our recent changes with the following code in random_builder (in
map_builders/mod.rs ):
This demonstrates the power of our approach - we're putting a lot of functionality
together from small building blocks. In this example we are:
We can also open map_builders/mod.rs and delete the MapBuilder trait and its
Randomize
As usual, we'd like to go back to having map generation be random. We're going to
break the process up into two steps. We'll make a new function,
random_initial_builder that rolls a dice and picks the starting builder. It also returns
a bool , indicating whether or not we picked an algorithm that provides room data.
The basic function should look familiar, but we've got rid of all the Box::new calls -
the constructors make boxes for us, now:
This is a pretty straightforward function - we roll a dice, match on the result table and
return the builder and room information we picked. Now we'll modify our
random_builder function to use it:
if rng.roll_dice(1, 3)==1 {
builder.with(WaveformCollapseBuilder::new());
}
if rng.roll_dice(1, 20)==1 {
builder.with(PrefabBuilder::sectional(prefab_builder::prefab_sections::UNDERGROUND_FOR
}
builder.with(PrefabBuilder::vaults());
builder
}
Wrap-Up
This has been an enormous chapter, but we've accomplished a lot:
This sets the stage for the next chapter, which will look at more ways to use �lters to
modify your map.
...
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:
for y in 1..build_data.map.height-1 {
for x in 1..build_data.map.width-1 {
let idx = build_data.map.xy_idx(x, y);
let mut neighbors = 0;
if build_data.map.tiles[idx - 1] == TileType::Wall { neighbors
+= 1; }
if build_data.map.tiles[idx + 1] == TileType::Wall { neighbors
+= 1; }
if build_data.map.tiles[idx - build_data.map.width as usize]
== TileType::Wall { neighbors += 1; }
if build_data.map.tiles[idx + build_data.map.width as usize]
== TileType::Wall { neighbors += 1; }
if build_data.map.tiles[idx - (build_data.map.width as usize -
1)] == TileType::Wall { neighbors += 1; }
if build_data.map.tiles[idx - (build_data.map.width as usize +
1)] == TileType::Wall { neighbors += 1; }
if build_data.map.tiles[idx + (build_data.map.width as usize -
1)] == TileType::Wall { neighbors += 1; }
if build_data.map.tiles[idx + (build_data.map.width as usize +
1)] == TileType::Wall { neighbors += 1; }
build_data.map.tiles = newtiles.clone();
build_data.take_snapshot();
}
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.
If you cargo run the project now, you'll see something like this:
The Drunken Walk algorithm can also make a nice post-processing e�ect, with very
minimal modi�cation. In drunkard.rs , simply add the following:
If you cargo run the project, you'll see something like this:
Notice how the initial boxy design now looks a bit more natural, because drunken
dwarves have carved out sections of the map!
DLA can also be modi�ed to erode an existing, boxy map. Simply add the
MetaBuilder trait to dla.rs :
We'll also add a new mode, heavy_erosion - it's the same as "walk inwards", but
wants a greater percentage of �oor space:
#[allow(dead_code)]
pub fn heavy_erosion() -> Box<DLABuilder> {
Box::new(DLABuilder{
algorithm: DLAAlgorithm::WalkInwards,
brush_size: 2,
symmetry: Symmetry::None,
floor_percent: 0.35,
})
}
If you cargo run the project, you'll see something like this:
Eroding rooms
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 o�
dynamite in each room. In map_builders/ , make a new �le room_exploder.rs :
impl RoomExploder {
#[allow(dead_code)]
pub fn new() -> Box<RoomExploder> {
Box::new(RoomExploder{})
}
}
paint(&mut build_data.map, Symmetry::None, 1,
drunk_x, drunk_y);
build_data.map.tiles[drunk_idx] =
TileType::DownStairs;
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/ :
impl RoomCornerRounder {
#[allow(dead_code)]
pub fn new() -> Box<RoomCornerRounder> {
Box::new(RoomCornerRounder{})
}
if neighbor_walls == 2 {
build_data.map.tiles[idx] = TileType::Wall;
}
}
structures");
}
build_data.take_snapshot();
}
}
}
The boilerplate (repeated code) should look familiar by now, so we'll focus on the
algorithm in build :
The result (if you cargo run ) should be something like this:
impl SimpleMapBuilder {
#[allow(dead_code)]
pub fn new() -> Box<SimpleMapBuilder> {
Box::new(SimpleMapBuilder{})
}
for _i in 0..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);
let mut 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);
}
}
impl DoglegCorridors {
#[allow(dead_code)]
pub fn new() -> Box<DoglegCorridors> {
Box::new(DoglegCorridors{})
}
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:
Testing it with cargo run should show you that rooms are built, and then corridors:
if self.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 �le
map_builders/rooms_corridors_bsp.rs :
impl BspCorridors {
#[allow(dead_code)]
pub fn new() -> Box<BspCorridors> {
Box::new(BspCorridors{})
}
for i in 0..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 �tted into its own
builder stage. You can prove that it works by modifying random_builder once again:
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 :
impl RoomSorter {
#[allow(dead_code)]
pub fn new() -> Box<RoomSorter> {
Box::new(RoomSorter{})
}
This is exactly the same sorting we used before, and we can test it by inserting it into
our builder sequence:
That's better - we've restored the look and feel of our BSP Dungeon Builder!
impl RoomSorter {
#[allow(dead_code)]
pub fn new(sort_by : RoomSort) -> Box<RoomSorter> {
Box::new(RoomSorter{ sort_by })
}
Simple enough: we store the sorting algorithm we wish to use in the structure, and
match on it when it comes time to execute.
That's so simple it's basically cheating! Lets add TOPMOST and BOTTOMMOST as well,
for completeness of this type of sort:
#[allow(dead_code)]
pub enum RoomSort { LEFTMOST, RIGHTMOST, TOPMOST, BOTTOMMOST }
...
fn sorter(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut
BuilderMap) {
match self.sort_by {
RoomSort::LEFTMOST =>
build_data.rooms.as_mut().unwrap().sort_by(|a,b| a.x1.cmp(&b.x1) ),
RoomSort::RIGHTMOST =>
build_data.rooms.as_mut().unwrap().sort_by(|a,b| b.x2.cmp(&a.x2) ),
RoomSort::TOPMOST =>
build_data.rooms.as_mut().unwrap().sort_by(|a,b| a.y1.cmp(&b.y1) ),
RoomSort::BOTTOMMOST =>
build_data.rooms.as_mut().unwrap().sort_by(|a,b| b.y2.cmp(&a.y2) )
}
}
See how that changes the character of the map without really changing the structure?
It's amazing what you can do with little tweaks!
We'll add another sort, CENTRAL. This time, we're sorting by distance from the map
center:
#[allow(dead_code)]
pub enum RoomSort { LEFTMOST, RIGHTMOST, TOPMOST, BOTTOMMOST, CENTRAL }
...
fn sorter(&mut self, _rng : &mut RandomNumberGenerator, build_data : &mut
BuilderMap) {
match self.sort_by {
RoomSort::LEFTMOST =>
build_data.rooms.as_mut().unwrap().sort_by(|a,b| a.x1.cmp(&b.x1) ),
RoomSort::RIGHTMOST =>
build_data.rooms.as_mut().unwrap().sort_by(|a,b| b.x2.cmp(&a.x2) ),
RoomSort::TOPMOST =>
build_data.rooms.as_mut().unwrap().sort_by(|a,b| a.y1.cmp(&b.y1) ),
RoomSort::BOTTOMMOST =>
build_data.rooms.as_mut().unwrap().sort_by(|a,b| b.y2.cmp(&a.y2) ),
RoomSort::CENTRAL => {
let map_center = rltk::Point::new( build_data.map.width / 2,
build_data.map.height / 2 );
let center_sort = |a : &Rect, b : &Rect| {
let a_center = a.center();
let a_center_pt = rltk::Point::new(a_center.0,
a_center.1);
let b_center = b.center();
let b_center_pt = rltk::Point::new(b_center.0,
b_center.1);
let distance_a =
rltk::DistanceAlg::Pythagoras.distance2d(a_center_pt, map_center);
let distance_b =
rltk::DistanceAlg::Pythagoras.distance2d(b_center_pt, map_center);
distance_a.partial_cmp(&distance_b).unwrap()
};
build_data.rooms.as_mut().unwrap().sort_by(center_sort);
}
}
}
Notice how all roads now lead to the middle - for a very connected map!
match exit_roll {
1 => builder.with(RoomBasedStairs::new()),
_ => builder.with(DistantExit::new())
}
That's a big function, so we'll step through it. It's quite simple, just really spread out
and full of branches:
1. We roll 1d3, and pick from BSP Interior, Simple and BSP Dungeon map builders.
2. If we didn't pick BSP Interior (which does a lot of stu� itself), we:
1. Randomly pick a room sorting algorithm.
2. Randomly pick one of the two corridor algorithms we now have.
3. Randomly pick (or ignore) a room exploder or corner-rounder.
3. 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.
4. We randomly choose between a Room-based stairs placement and a "most
distant from the start" exit.
5. 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:
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.
if rng.roll_dice(1, 3)==1 {
builder.with(WaveformCollapseBuilder::new());
}
if rng.roll_dice(1, 20)==1 {
builder.with(PrefabBuilder::sectional(prefab_builder::prefab_sections::UNDERGROUND_FOR
}
builder.with(PrefabBuilder::vaults());
builder
}
So how does our total combinatorial explosion look? Pretty good at this point:
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.
...
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.
In the last chapter, we abstracted out room layout - but kept the actual placement of
the rooms the same: they are always rectangles, although this can be mitigated with
room explosion and corner rounding. This chapter will add the ability to use rooms of
di�erent shapes.
impl RoomDrawer {
#[allow(dead_code)]
pub fn new() -> Box<RoomDrawer> {
Box::new(RoomDrawer{})
}
We'll also have to update is_possible to check the rooms list rather than reading
the live map (to which we haven't written anything):
for r in rooms.iter() {
if r.intersect(&rect) { can_build = false; }
}
can_build
}
if ok {
rooms.push(new_room);
}
if rng.roll_dice(1, 3)==1 {
builder.with(WaveformCollapseBuilder::new());
}
if rng.roll_dice(1, 20)==1 {
builder.with(PrefabBuilder::sectional(prefab_builder::prefab_sections::UNDERGROUND_FOR
}
builder.with(PrefabBuilder::vaults());
builder*/
If you cargo run the project, you'll see our simple map builder run - just like before.
Circular Rooms
Simply moving the draw code out of the algorithm cleans things up, but doesn't gain
us anything new. So we'll look at adding a few shape options for rooms. We'll start by
moving the draw code out of the main loop and into its own function. Modify
room_draw.rs as follows:
Once again, if you feel like testing it - cargo run will give you similar results to last
time. Lets add a second room shape - circular rooms:
Now replace your call to rectangle with circle , type cargo run and enjoy the new
room type:
If you cargo run the project now, you'll see something like this:
Restoring randomness
In map_builders/mod.rs uncomment the code and remove the test harness:
if rng.roll_dice(1, 3)==1 {
builder.with(WaveformCollapseBuilder::new());
}
if rng.roll_dice(1, 20)==1 {
builder.with(PrefabBuilder::sectional(prefab_builder::prefab_sections::UNDERGROUND_FOR
}
builder.with(PrefabBuilder::vaults());
builder
}
...
let 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)),
}
builder.with(RoomDrawer::new());
You can now get the full gamut of random room creation - but with the occasional
round instead of rectangular room. That adds a bit more variety to the mix.
...
Improved corridors
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.
Our corridor generation so far has been quite primitive, featuring overlaps - and
unless you use Voronoi spawning, nothing in them. This chapter will try to o�er a few
more generation strategies (in turn providing even more map variety), and allow
hallways to contain entities.
impl NearestCorridors {
#[allow(dead_code)]
pub fn new() -> Box<NearestCorridors> {
Box::new(NearestCorridors{})
}
);
room_distance.push((j, distance));
}
}
if !room_distance.is_empty() {
room_distance.sort_by(|a,b| a.1.partial_cmp(&b.1).unwrap()
);
let dest_center = rooms[room_distance[0].0].center();
draw_corridor(
&mut build_data.map,
room_center.0, room_center.1,
dest_center.0, dest_center.1
);
connected.insert(i);
build_data.take_snapshot();
}
}
}
}
There's some boilerplate with which you should be familiar by now, so lets walk
through the corridors function:
1. We start by obtaining the rooms list, and panic! if there isn't one.
2. We make a new HashSet named connected . We'll add rooms to this as they
gain exits, so as to avoid linking repeatedly to the same room.
3. For each room, we retrieve an "enumeration" called i (the index number in the
vector) and the room :
1. We create a new vector called room_distance . It stores tuples containing
the room being considered's index and a �oating point number that will
store its distance to the current room.
2. We calculate the center of the room, and store it in a Point from RLTK (for
compatibility with the distance algorithms).
3. For every room, we retrieve an enumeration called j (it's customary to use
i and j for counters, presumably dating back to the days in which longer
variable names were expensive!), and the other_room .
1. If i and j are equal, we are looking at a corridor to/from the same
room. We don't want to do that, so we skip it!
2. Likewise, if the other_room 's index ( j ) is in our connected set, then
we don't want to evaluate it either - so we skip that.
3. We calculate the distance from the outer room ( room / i ) to the room
if rng.roll_dice(1, 3)==1 {
builder.with(WaveformCollapseBuilder::new());
}
if rng.roll_dice(1, 20)==1 {
builder.with(PrefabBuilder::sectional(prefab_builder::prefab_sections::UNDERGROUND_FOR
}
builder.with(PrefabBuilder::vaults());
builder*/
This gives nicely connected maps, with sensibly short corridor distances. If you
cargo run the project, you should see something like this:
impl StraightLineCorridors {
#[allow(dead_code)]
pub fn new() -> Box<StraightLineCorridors> {
Box::new(StraightLineCorridors{})
}
);
room_distance.push((j, distance));
}
}
if !room_distance.is_empty() {
room_distance.sort_by(|a,b| a.1.partial_cmp(&b.1).unwrap()
);
let dest_center = rooms[room_distance[0].0].center();
let line = rltk::line2d(
rltk::LineAlg::Bresenham,
room_center_pt,
rltk::Point::new(dest_center.0, dest_center.1)
);
for cell in line.iter() {
let idx = build_data.map.xy_idx(cell.x, cell.y);
build_data.map.tiles[idx] = TileType::Floor;
}
connected.insert(i);
build_data.take_snapshot();
}
}
}
}
This is almost the same as the previous one, but instead of calling draw_corridor we
use RLTK's line function to plot a line from the center of the source and destination
rooms. We then mark each tile along the line as a �oor. If you modify your
random_builder to use this:
Then cargo run your project, you will see something like this:
We also need to adjust the constructor to ensure that corridors isn't forgotten:
impl BuilderChain {
pub fn new(new_depth : i32) -> BuilderChain {
BuilderChain{
starter: None,
builders: Vec::new(),
build_data : BuilderMap {
spawn_list: Vec::new(),
map: Map::new(new_depth),
starting_position: None,
rooms: None,
corridors: None,
history : Vec::new()
}
}
}
...
Now in common.rs , lets modify our corridor functions to return corridor placement
information:
while x != x2 || y != y2 {
if x < x2 {
x += 1;
} else if x > x2 {
x -= 1;
} else if y < y2 {
y += 1;
} else if y > y2 {
y -= 1;
}
corridor
}
Notice that they are essentially unchanged, but now return a vector of tile indices -
and only add to them if the tile being modi�ed is a �oor? That will give us de�nitions
for each leg of a corridor. Now we need to modify the corridor drawing algorithms to
store this information. In rooms_corridors_bsp.rs , modify the corridors function
to do this:
...
let mut corridors : Vec<Vec<usize>> = Vec::new();
for i in 0..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);
let corridor = draw_corridor(&mut build_data.map, start_x, start_y,
end_x, end_y);
corridors.push(corridor);
build_data.take_snapshot();
}
build_data.corridors = Some(corridors);
...
...
let mut corridors : Vec<Vec<usize>> = Vec::new();
for (i,room) in rooms.iter().enumerate() {
if i > 0 {
let (new_x, new_y) = room.center();
let (prev_x, prev_y) = rooms[rooms.len()-1].center();
if rng.range(0,1) == 1 {
let mut c1 = apply_horizontal_tunnel(&mut build_data.map,
prev_x, new_x, prev_y);
let mut c2 = apply_vertical_tunnel(&mut build_data.map,
prev_y, new_y, new_x);
c1.append(&mut c2);
corridors.push(c1);
} else {
let mut c1 = apply_vertical_tunnel(&mut build_data.map,
prev_y, new_y, prev_x);
let mut c2 = apply_horizontal_tunnel(&mut build_data.map,
prev_x, new_x, new_y);
c1.append(&mut c2);
corridors.push(c1);
}
build_data.take_snapshot();
}
}
build_data.corridors = Some(corridors);
...
You'll notice that we append the second leg of the corridor to the �rst, so we treat it
as one long corridor rather than two hallways. We need to apply the same change to
our newly minted rooms_corridors_lines.rs :
if !room_distance.is_empty() {
room_distance.sort_by(|a,b| a.1.partial_cmp(&b.1).unwrap() );
let dest_center = rooms[room_distance[0].0].center();
let line = rltk::line2d(
rltk::LineAlg::Bresenham,
room_center_pt,
rltk::Point::new(dest_center.0, dest_center.1)
);
let mut corridor = Vec::new();
for cell in line.iter() {
let idx = build_data.map.xy_idx(cell.x, cell.y);
if build_data.map.tiles[idx] != TileType::Floor {
build_data.map.tiles[idx] = TileType::Floor;
corridor.push(idx);
}
}
corridors.push(corridor);
connected.insert(i);
build_data.take_snapshot();
}
}
build_data.corridors = Some(corridors);
}
if !room_distance.is_empty() {
room_distance.sort_by(|a,b| a.1.partial_cmp(&b.1).unwrap() );
let dest_center = rooms[room_distance[0].0].center();
let corridor = draw_corridor(
&mut build_data.map,
room_center.0, room_center.1,
dest_center.0, dest_center.1
);
connected.insert(i);
build_data.take_snapshot();
corridors.push(corridor);
}
}
build_data.corridors = Some(corridors);
}
impl CorridorSpawner {
#[allow(dead_code)]
pub fn new() -> Box<CorridorSpawner> {
Box::new(CorridorSpawner{})
}
per room - we pass the corridor to spawn_region . Entities now spawn in the hallways.
Once you are playing, you can now �nd entities inside your corridors:
Restoring Randomness
Once again, it's the end of a sub-section - so we'll make random_builder random
once more, but utilizing our new stu�!
Start by uncommenting the code in random_builder , and removing the test harness:
if rng.roll_dice(1, 3)==1 {
builder.with(WaveformCollapseBuilder::new());
}
if rng.roll_dice(1, 20)==1 {
builder.with(PrefabBuilder::sectional(prefab_builder::prefab_sections::UNDERGROUND_FOR
}
builder.with(PrefabBuilder::vaults());
builder
}
Since everything we've worked on here has been room based, we'll also modify
random_room_builder to include it. We'll expand the corridor related section:
...
Doors
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-� 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.
match spawn.1.as_ref() {
"Goblin" => goblin(ecs, x, y),
"Orc" => orc(ecs, x, y),
"Health Potion" => health_potion(ecs, x, y),
"Fireball Scroll" => fireball_scroll(ecs, x, y),
"Confusion Scroll" => confusion_scroll(ecs, x, y),
"Magic Missile Scroll" => magic_missile_scroll(ecs, x, y),
"Dagger" => dagger(ecs, x, y),
"Shield" => shield(ecs, x, y),
"Longsword" => longsword(ecs, x, y),
"Tower Shield" => tower_shield(ecs, x, y),
"Rations" => rations(ecs, x, y),
"Magic Mapping Scroll" => magic_mapping_scroll(ecs, x, y),
"Bear Trap" => bear_trap(ecs, x, y),
"Door" => door(ecs, x, y),
_ => {}
}
We won't add doors to the spawn tables; it wouldn't make a lot of sense for them to
randomly appear in rooms!
Placing doors
We'll create a new builder (we're still in the map section, after all!) that can place
doors. So in map_builders , make a new �le: door_placement.rs :
impl DoorPlacement {
#[allow(dead_code)]
pub fn new() -> Box<DoorPlacement> {
Box::new(DoorPlacement{ })
}
This is an empty skeleton of a meta-builder. Let's deal with the easiest case �rst: when
we have corridor data, that provides something of a blueprint as to where doors
might �t. We'll start with a new function, door_possible :
false
}
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:
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:
if rng.roll_dice(1, 3)==1 {
builder.with(WaveformCollapseBuilder::new());
}
if rng.roll_dice(1, 20)==1 {
builder.with(PrefabBuilder::sectional(prefab_builder::prefab_sections::UNDERGROUND_FOR
}
builder.with(DoorPlacement::new());
builder.with(PrefabBuilder::vaults());
builder
}
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.
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.
As with all components, don't forget to register them both in main and in
saveload_system.rs .
Since �eld of view is handled by RLTK, which relies upon a Map trait - we need to
extend our map class to handle the concept. Add a new �eld:
#[serde(skip_serializing)]
#[serde(skip_deserializing)]
pub tile_content : Vec<Vec<Entity>>
}
Now we'll update the is_opaque function (used by �eld-of-view) to include a check
against it:
We'll also have to visit visibility_system.rs to populate this data. We'll need to
extend the system's data to retrieve a little more:
Right after that, we'll loop through all entities that block visibility and set their index in
the view_blocked HashSet :
map.view_blocked.clear();
for (block_pos, _block) in (&pos, &blocks_visibility).join() {
let idx = map.xy_idx(block_pos.x, block_pos.y);
map.view_blocked.insert(idx);
}
If you cargo run the project now, you'll see that doors now block line-of-sight:
Handling Doors
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 :
...
let mut doors = ecs.write_storage::<Door>();
let mut blocks_visibility = ecs.write_storage::<BlocksVisibility>();
let mut blocks_movement = ecs.write_storage::<BlocksTile>();
let mut renderables = ecs.write_storage::<Renderable>();
the door.
If you cargo run the project now, you get the desired functionality:
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.
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.
if rng.roll_dice(1, 3)==1 {
builder.with(WaveformCollapseBuilder::new());
Wrap-Up
That's it for doors! There's de�nitely 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 de�nition of a door!
...
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.
So far, we've �rmly tied map size to terminal resolution. You have an 80x50 screen,
and use a few lines for the user interface - so everything we've made is 80 tiles wide
and 43 tiles high. As you've seen in previous chapters, you can do a lot with 3,440 tiles
- but sometimes you want more (and sometimes you want less). You may also want a
big, open world setting - but we're not going to go there yet! This chapter will start by
decoupling the camera from the map, and then enable map size and screen size to
di�er. The di�cult topic of resizing the user interface will be left for future
development.
Introducing a Camera
A common abstraction in games is to separate what you are viewing (the map and
entities) from how you are viewing it - the camera. The camera typically follows your
brave adventurer around the map, showing you the world from their point of view. In
3D games, the camera can be pretty complicated; in top-down roguelikes (viewing the
map from above), it typically centers the view on the player's @ .
Predictably enough, we'll start by making a new �le: camera.rs . To enable it, add
pub mod camera towards the top of main.rs (with the other module access).
We'll start out by making a function, render_camera , and doing some calculations
we'll need:
use specs::prelude::*;
use super::{Map,TileType,Position,Renderable,Hidden};
use rltk::{Point, Rltk, Console, RGB};
I've broken this down into steps to make it clear what's going on:
So we've established where the camera is in world space - that is, coordinates on the
map itself. We've also established that with our camera view, that should be the center
of the rendered area.
let mut y = 0;
for ty in min_y .. max_y {
let mut x = 0;
for tx in min_x .. max_x {
if tx > 0 && tx < map_width && ty > 0 && ty < map_height {
let idx = map.xy_idx(tx, ty);
if map.revealed_tiles[idx] {
let (glyph, fg, bg) = get_tile_glyph(idx, &*map);
ctx.set(x, y, fg, bg, glyph);
}
} else if SHOW_BOUNDARIES {
ctx.set(x, y, RGB::named(rltk::GRAY), RGB::named(rltk::BLACK),
rltk::to_cp437('·'));
}
x += 1;
}
y += 1;
}
This is similar to our old draw_map code, but a little more complicated. Lets walk
through it:
That's actually quite simple - we're rendering what is e�ectively a window looking into
part of the map, rather than the whole map - and centering the window on the player.
If this looks familiar, it's because it's the same as the render code that used to live in
main.rs . There are two major di�erences: we subtract min_x and min_y from the
x and y coordinates, to line the entities up with our camera view. We also perform
clipping on the coordinates - we won't try and render anything that isn't on the screen.
match map.tiles[idx] {
TileType::Floor => {
glyph = rltk::to_cp437('.');
fg = RGB::from_f32(0.0, 0.5, 0.5);
}
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);
}
}
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
}
This is very similar to the code from draw_map we wrote ages ago, but instead of
drawing to the map it returns a glyph, foreground and background colors. It still
handles bloodstains, greying out areas that you can't see, and calls wall_glyph for
nice walls. We've simply copied wall_glyph over from map.rs :
match mask {
0 => { 9 } // Pillar because we can't see neighbors
1 => { 186 } // Wall only to the north
2 => { 186 } // Wall only to the south
3 => { 186 } // Wall to the north and south
4 => { 205 } // Wall only to the west
5 => { 188 } // Wall to the north and west
6 => { 187 } // Wall to the south and west
7 => { 185 } // Wall to the north, south and west
8 => { 205 } // Wall only to the east
9 => { 200 } // Wall to the north and east
10 => { 201 } // Wall to the south and east
11 => { 204 } // Wall to the north, south and east
12 => { 205 } // Wall to the east and west
13 => { 202 } // Wall to the east, west, and south
14 => { 203 } // Wall to the east, west, and north
_ => { 35 } // We missed one?
}
}
...
RunState::GameOver{..} => {}
_ => {
draw_map(&self.ecs.fetch::<Map>(), ctx);
let positions = self.ecs.read_storage::<Position>();
let renderables = self.ecs.read_storage::<Renderable>();
let hidden = self.ecs.read_storage::<Hidden>();
let map = self.ecs.fetch::<Map>();
RunState::GameOver{..} => {}
_ => {
camera::render_camera(&self.ecs, ctx);
gui::draw_ui(&self.ecs, ctx);
}
If you cargo run the project now, you'll see that we can still play - and the camera is
centered on the player:
If you play for a bit, you'll probably notice that tool-tips aren't working (they are still
bound to the map coordinates). We should �x that! First of all, it's becoming obvious
that the screen boundaries are something we'll need in more than just the drawing
code, so lets break it into a separate function in camera.rs :
pub fn get_screen_bounds(ecs: &World, ctx : &mut Rltk) -> (i32, i32, i32,
i32) {
let player_pos = ecs.fetch::<Point>();
let (x_chars, y_chars) = ctx.get_char_size();
It's the same code from render_camera - just moved into a function. We've also
extended render_camera to use the function, rather than repeating ourselves. Now
we can go into gui.rs and edit draw_tooltips to use the camera position quite
easily:
Fixing Targeting
If you play for a bit, you'll also notice if you try and use a �reball or similar e�ect - the
targeting system is completely out of whack. It's still referencing the screen/map
positions from when they were directly linked. So you see the available tiles, but they
are in completely the wrong place! We should �x that, too.
pub fn ranged_target(gs : &mut State, ctx : &mut Rltk, range : i32) ->
(ItemMenuResult, Option<Point>) {
let (min_x, max_x, min_y, max_y) = camera::get_screen_bounds(&gs.ecs,
ctx);
let player_entity = gs.ecs.fetch::<Entity>();
let player_pos = gs.ecs.fetch::<Point>();
let viewsheds = gs.ecs.read_storage::<Viewshed>();
ctx.print_color(5, 0, RGB::named(rltk::YELLOW),
RGB::named(rltk::BLACK), "Select Target:");
return (ItemMenuResult::Selected,
Some(Point::new(mouse_map_pos.0, mouse_map_pos.1)));
}
} else {
ctx.set_bg(mouse_pos.0, mouse_pos.1, RGB::named(rltk::RED));
if ctx.left_click {
return (ItemMenuResult::Cancel, None);
}
}
(ItemMenuResult::NoResponse, None)
}
You may encounter a crash when boundaries haven't properly been applied to the
edge of the map. In visibility_system.rs , the following �x takes care of it:
This will be merged back into the tutorial soon - this addendum is to let you know we
�xed it.
An easy start
Let's start with the simplest possible case: changing the size of the map globally. Go to
map.rs , and �nd the constants MAPWIDTH , MAPHEIGHT and MAPCOUNT . Lets change
them to a square map:
If you cargo run the project, it should work - we've been pretty good about using
either map.width / map.height or these constants throughout the program. The
algorithms run, and try to make a map for your use. Here's our player wandering a
64x64 map - note how the sides of the map are displayed as out-of-bounds:
Now delete the three constants from map.rs , and watch your IDE paint the world red.
Before we start �xing things, we'll add a bit more red:
Now creating a map requires that you specify a size as well as depth. We can make a
start on �xing some errors by changing the constructor once more to use the speci�ed
size in creating the various vectors:
...
// Move the coordinates
x += 1;
if x > (map.width * map.height) as i32-1 {
x = 0;
y += 1;
}
...
spawner.rs is an equally easy �x. Remove map::MAPWIDTH from the list of use
imports at the beginning, and �nd the spawn_entity function. We can obtain the
map width from the ECS directly:
The issue in saveload_system.rs is also easy to �x. Around line 102, you can replace
MAPCOUNT with (worldmap.width * worldmap.height) as usize :
...
let mut deleteme : Option<Entity> = None;
{
let entities = ecs.entities();
let helper = ecs.read_storage::<SerializationHelper>();
let player = ecs.read_storage::<Player>();
let position = ecs.read_storage::<Position>();
for (e,h) in (&entities, &helper).join() {
let mut worldmap = ecs.write_resource::<super::map::Map>();
*worldmap = h.map.clone();
worldmap.tile_content = vec![Vec::new(); (worldmap.height *
worldmap.width) as usize];
deleteme = Some(e);
}
...
main.rs also needs some help. In tick , the MagicMapReveal code is a simple �x:
RunState::MagicMapReveal{row} => {
let mut map = self.ecs.fetch_mut::<Map>();
for x in 0..map.width {
let idx = map.xy_idx(x as i32,row);
map.revealed_tiles[idx] = true;
}
if row == map.height-1 {
newrunstate = RunState::MonsterTurn;
} else {
newrunstate = RunState::MagicMapReveal{ row: row+1 };
}
}
Down around line 451, we're also making a map with map::new(1) . We want to
introduce a map size here, so we go with map::new(1, 64, 64) (the size doesn't
really matter since we'll be replacing it with a map from a builder anyway).
Open up player.rs and you'll �nd that we've committed a real programming sin.
We've hard-coded 79 and 49 as map boundaries for player movement! Let's �x that:
if !map.blocked[destination_idx] {
pos.x = min(map.width-1 , max(0, pos.x + delta_x));
pos.y = min(map.height-1, max(0, pos.y + delta_y));
Finally, expanding our map_builders folder reveals a few errors. We're going to
introduce a couple more before we �x them! In map_builders/mod.rs we'll store the
requested map size:
impl BuilderChain {
pub fn new(new_depth : i32, width: i32, height: i32) -> BuilderChain {
BuilderChain{
starter: None,
builders: Vec::new(),
build_data : BuilderMap {
spawn_list: Vec::new(),
map: Map::new(new_depth, width, height),
starting_position: None,
rooms: None,
corridors: None,
history : Vec::new(),
width,
height
}
}
}
We also need to adjust the signature for random_builder to accept a map size:
Finally, go back to main.rs and around line 370 you'll �nd our call to
random_builder . We need to add a width and height to it; for now, we'll use 64x64:
And that's it! If you cargo run the project now, you can roam a 64x64 map:
If you change that line to di�erent sizes, you can roam a huge map:
Voila - you are roaming a huge map! A de�nite downside of a huge map, and rolling a
largely open area is that sometimes it can be really di�cult to survive:
let mut y = 0;
for ty in min_y .. max_y {
let mut x = 0;
for tx in min_x .. max_x {
if tx > 0 && tx < map_width && ty > 0 && ty < map_height {
let idx = map.xy_idx(tx, ty);
if map.revealed_tiles[idx] {
let (glyph, fg, bg) = get_tile_glyph(idx, &*map);
ctx.set(x, y, fg, bg, glyph);
}
} else if SHOW_BOUNDARIES {
ctx.set(x, y, RGB::named(rltk::GRAY),
RGB::named(rltk::BLACK), rltk::to_cp437('·'));
}
x += 1;
}
y += 1;
}
}
This is a lot like our regular map drawing, but we lock the camera to the middle of the
map - and don't render entities.
camera::render_debug_map(&self.mapgen_history[self.mapgen_index], ctx);
Now you can go into map.rs and remove draw_map , wall_glyph and
is_revealed_and_wall completely.
Wrap-Up
We'll set the map size back to something reasonable in main.rs :
And - we're done! In this chapter, we've made it possible to have any size of map you
like. We've reverted to a "normal" size at the end - but we'll �nd this feature very
useful in the future. We can scale maps up or down - and the system won't mind at all.
...
Section 3 - Wrap Up
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.
And that wraps up section 3 - map building! We've covered a lot of ground in this
section, learning many techniques for map building. I hope it has inspired you to
search for your own interesting combinations, and make fun games! Procedurally
generating maps is a huge part of making a roguelike, hence it being such a large part
of 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.
Now we're going to start a series of articles that actually makes a cohesive game from
our framework. It won't be huge, and it's unlikely to challenge for "best Roguelike
ever!" status - but it will explore the trials and tribulations that go with turning a tech
demo into a cohesive game.
So we'll have a fantasy setting, dungeon diving, and limited progression. If you're
familiar with the Berlin Interpretation (an attempt at codifying what counts as a
roguelike in a world of games using the name!), we'll try to stick closely to the
important aspects:
High-value targets
Low-value targets
Single player character - we're unlikely to introduce groups in this section, but we
might introduce friendly NPCs.
Monsters are similar to players - the ECS helps with this, since we're simulating the
player in the same way as NPCs. We'll stick to the basic principle.
Tactical challenge - always something to strive for; what good is a game without
challenge?
ASCII Display - we'll be sticking with this, but may �nd time to introduce graphical
tiles later.
Dungeons - of course! They don't have to be rooms and corridors, but we've
worked hard to have good rooms and corridors!
Numbers - this one is a little more controversial; not everyone wants to see a
giant wall of math every time they punch a goblin. We'll try for some balance - so
there are plenty of numbers, mostly visible, but they aren't essential to playing
the game.
So it seems pretty likely that with this constraints we will be making a real roguelike -
one that checks almost all of the boxes!
Setting
We've already decided on a fantasy-faux-medieval setting, but that doesn't mean it
has to be just like D&D or Tolkien! We'll try and introduce some fun and unique
elements in our setting.
Narrative
In the next chapter, we'll work on outlining our overall objective in a design document.
This will necessarily include some narrative, although roguelikes aren't really known
for deep stories!
...
Design Document
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.
If you plan to �nish a game, it's important to set out your objectives ahead of time!
Traditionally, this has taken the form of a design document - a master document
outlining the game, and smaller sections detailing what you want to accomplish. In
this case, it also forms the skeleton of writing the section. There are thousands of
online references to writing game design documents. The format really doesn't matter
so long as it acts as a guiding light for development and gives you criteria for which
you can say "this is done!"
Because this is a tutorial, we're going to make the game design document a skeleton
for now, and �esh it out as we progress. That leaves some �exibility in writing the
guide on my end! So until this section is approaching complete, consider this to be a
living document - a perpetual work in progress, being expanded as we go. That's really
not how one should write a design document, but I have two luxuries that most teams
don't: no time limit, and no team members to direct!
Rusty Roguelike
Rusty Roguelike is a 2D traditional roguelike that attempts to capture the essentials of
the genre as it has developed since Rogue's release in 1980. Turn-based, tile-based
and centered on an adventurer's descent into a dungeon to retrieve the Amulet of
Yala (Yet Another Lost Amulet). The adventurer battles through numerous
procedurally generated levels to retrieve the amulet, and then must �ght their way
back to town to win the game.
Characters
The player controls one major character, Hero Protagonist as he/she/it battles through
the dungeon. Human NPCs will range from shop-keepers to fantasy RPG staples such
as bandits, brigands, sorcerers, etc. Other characters in the game will largely be
fantasy RPG staples: elves, dwarves, gnomes, hal�ings, orcs, goblins, trolls, ogres,
dragons, etc.
A stretch goal is to have NPCs belong to factions, and allow the clever player to
"faction farm" and adjust loyalties.
Story
This is not a story heavy game (Roguelikes are frequently shorter in story than
traditional RPGs, because you die and restart a lot and won't generally spend a lot of
time reading story/lore).
In the dark ages of yore, the sorcerer kings crafted the Amulet of Yala to bind the demons
of the Abyss - and end their reign of terror. A Golden Age followed, and the good races
�ourished. Now dark times have fallen upon the land once more, demons stir, and the
forces of darkness once again ravage the land. The Amulet of Yala may be the good folk's
last hope. After a long night in the pub, you realize that maybe it is your destiny to recover
it and restore tranquility to the land. Only slightly hungover, you set forth into the
dungeons beneath your home town - sure that you can be the one to set things right.
Theme
We'll aim for a traditional D&D style dungeon bash, with traps, monsters, the
occasional puzzle and "replayability". The game should be di�erent every time. A light-
hearted approach is preferred, with humor sprinkled liberally (another staple of the
genre). A "kitchen sink" approach is preferred to strictly focused realism - this is a
tutorial project, and it's better to have lots of themes (from which to learn) than a
single cohesive one in this case.
Story Progression
There is no horizontal progression - you don't keep any bene�ts from previous runs
through the game. So you always start in the same place as a new character, and gain
bene�ts for a single run only. You can go both up and down in the dungeon, returning
to town to sell items and goods. Progression on levels is preserved until you �nd the
Amulet of Yala - at which point the universe truly is out to get you until you return
home.
As a starting guide, consider the following progression. It will evolve and become
more random as we work on the game.
1. 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
Travel should be facilitated with an equivalent of Town Portal scrolls from Diablo.
Gameplay
In a real game design document, we'd painstakingly describe each element here. For
the purposes of the tutorial, we'll add to the list as we write more.
Goals
Overall: The ultimate goal is to retrieve the Amulet of Yala - and return to town
(town portal spells stop working once you have it).
Short-term: Defeat enemies on each level.
Navigate each level of the dungeon, avoiding traps and reaching the exit.
Obtain lots of cool loot.
Earn bragging rights for your score.
User Skills
Game Mechanics
We'll go with the tried and tested "sort of D&D" mechanics used by so many games
(and licensed under the Open Gaming License), but without being tied to a D&D-like
game. We'll expand upon this as we develop the tutorial.
The game should include a good variety of items. Broadly, items are divided as:
Other notes:
Items should be drawn from loot tables that at least sort-of make sense.
"Props" are a special form of item that doesn't move, but can be interacted with.
As you defeat enemies, you earn experience points and can level up. This
improves your general abilities and grants access to better ways to defeat more
enemies!
The levels should increase in di�culty as you descend. "Out of level" enemies
are possible but very rare - to keep it fair.
Try to avoid capriciously killing the player with no hope of circumventing it.
Once the Amulet of Yala has been claimed, di�culty ramps up on all levels as you
�ght your way back up to town. Certain perks (like town portal) no longer work.
There is no progression between runs - it's entirely self-contained.
Losing
Losing is fun! In fact, a fair portion of the appeal of traditional roguelikes is that you
have one life - and it's "game over" when you succumb to your wounds/traps/being
turned into a banana. The game will feature permadeath - once you've died, your run
is over and you start afresh.
Art Style
None! It would be nice to have once tiles are done, but fully voicing a modern RPG is
far beyond my resources.
Technical Description
The game will be written in Rust, using RLTK_RS for its back-end. It will support all the
platforms on which Rust can compile and link to OpenGL, including Web Assembly for
browser-based play.
This is a free tutorial, so the budget is approximately $0. If anyone wants to donate to
my Patreon I can promise eternal gratitude, a monster in your honor, and not a lot
else!
Localization
Other Ideas
Anyone who has great ideas should send them to me. :-)
Wrap-Up
So there we have it: a very skeletal design document, with lots of holes in it. It's a good
idea to write one of these, especially when making a time-constrained game such as a
"7-day roguelike challenge". This chapter will keep improving in quality as more
features are implemented. For now, it's intended to serve as a baseline.
...
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.
If you've ever played Dwarf Fortress, one of its de�ning characteristics (under the
hood) is the raw �le system. Huge amounts of the game are detailed in the raws , and
you can completely "mod" the game into something else. Other games, such as Tome
4 take this to the extent of de�ning scripting engine �les for everything - you can
customize the game to your heart's content. Once implemented, raws turn your
game into more of an engine - displaying/managing interactions with content written
in the raw �les. That isn't to say the engine is simple: it has to support everything that
one speci�es in the raw �les!
This is called data-driven design: your game is de�ned by the data describing it, more
than the actual engine mechanics. It has a few advantages:
It makes it very easy to make changes; you don't have to dig through
spawner.rs every time you want to change a goblin, or make a new variant such
as a cowardly goblin . Instead, you edit the raws to include your new monster,
add him/her/it to spawn, loot and faction tables, and the monster is now in your
game! (Unless of course being cowardly requires new support code - in which
case you write that, too).
Data-driven design meshes beautifully with Entity Component Systems (ECS).
The raws serve as a template, from which you build your entities by composing
components until it matches your raw description.
Data-driven design makes it easy for people to change the game you've created.
For a tutorial such as this, this is pretty essential: I'd much rather you come out
of this tutorial able to go forth and make your own game, rather than just re-
hashing this one!
That gets rid of one advantage of data-driven design: you still have to recompile the
game. So we'll make the embedding optional; if we can read a �le from disk, we'll do
so. In practice, this will mean that when you ship your game, you have to include the
executable and the raw �les - or embed them in the �nal build.
Taking a look at spawner.rs in the current game should give us some clues as to
what to put into these �les. Thanks to our use of components, there's already a lot of
shared functionality we can build upon. For example, the de�nition for a health potion
looks like this:
{
"name" : "Healing Potion",
"renderable": {
"glyph" : "!",
"fg" : "#FF00FF",
"bg" : "#000000"
},
"consumable" : {
"effects" : { "provides_healing" : "8" }
}
}
| Root folder
\ - src (your source files)
At the root level, we'll make a new directory/folder called raws . So your tree should
look like this:
| Root folder
\ - src (your source files)
\ - raws
In this directory, create a new �le: spawns.json . We'll temporarily put all of our
de�nitions into one �le; this will change later, but we want to get support for our data-
driven ambitions bootstrapped. In this �le, we'll put de�nitions for some of the
entities we currently support in spawner.rs . We'll start with just a couple of items:
{
{
"items" : [
{
"name" : "Health Potion",
"renderable": {
"glyph" : "!",
"fg" : "#FF00FF",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : { "provides_healing" : "8" }
}
},
{
"name" : "Magic Missile Scroll",
"renderable": {
"glyph" : ")",
"fg" : "#00FFFF",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : {
"ranged" : "6",
"damage" : "20"
}
}
}
]
}
If you aren't familiar with the JSON format, it's basically a JavaScript dump of data:
We wrap the �le in { and } to denote the object we are loading. This will be our
Raws object, eventually.
Then we have an array called Items - which will hold our items.
Each Item has a name - this maps directly to the Name component.
Items may have a renderable structure, listing glyph, foreground and
background colors.
These items are consumable , and we list their e�ects in a "key/value map" -
basically a HashMap like we've used before, a Dictionary in other languages.
We'll be adding a lot more to the spawns list eventually, but lets start by making these
work.
rltk::embedded_resource!(RAW_FILE, "../../raws/spawns.json");
pub fn load_raws() {
rltk::link_resource!(RAW_FILE, "../../raws/spawns.json");
}
In our initialization, add a call to load_raws after component initialization and before
you start adding to World :
...
gs.ecs.register::<Door>();
gs.ecs.insert(SimpleMarkerAllocator::<SerializeMe>::new());
raws::load_raws();
The spawns.json �le will now be embedded into your executable, courtesy of RLTK's
embedding system.
This will panic (crash) if it isn't able to �nd the resource, or if it is unable to parse it as
a regular string (Rust likes UTF-8 Unicode encoding, so we'll go with it. It lets us include
extended glyphs, which we can parse via RLTK's to_cp437 function - so it works out
nicely!).
Now we need to actually parse the JSON into something usable. Just like our
saveload.rs system, we can do this with Serde. For now, we'll just dump the results
to the console so we can see that it did something:
(See the cryptic {:?} ? That's a way to print debug information about a structure). This
will fail to compile, because we haven't actually implemented Raws - the type it is
looking for.
For clarity, we'll put the classes that actually handle the data in their own �le,
raws/item_structs.rs . Here's the �le:
use serde::{Deserialize};
use std::collections::HashMap;
#[derive(Deserialize, Debug)]
pub struct Raws {
pub items : Vec<Item>
}
#[derive(Deserialize, Debug)]
pub struct Item {
pub name : String,
pub renderable : Option<Renderable>,
pub consumable : Option<Consumable>
}
#[derive(Deserialize, Debug)]
pub struct Renderable {
pub glyph: String,
pub fg : String,
pub bg : String,
pub order: i32
}
#[derive(Deserialize, Debug)]
pub struct Consumable {
pub effects : HashMap<String, String>
}
At the top of the �le, make sure to include use serde::{Deserialize}; and
use std::collections::HashMap; to include the types we need. Also notice that we
have included Debug in the derived types list. This allows Rust to print a debug copy
of the struct, so we can see what the code did. Notice also that a lot of things are an
Option . This way, the parsing will work if an item doesn't have that entry. It will make
reading them a little more complicated later on, but we can live with that!
If you cargo run the project now, ignore the game window - watch the console. You'll
see the following:
That's super ugly and horribly formatted, but you can see that it contains the data we
entered!
We want to create a structure to hold all of our raw data, and provide useful services
such as spawning an object entirely from the data in the raws . We'll make a new �le,
raws/rawmaster.rs :
use std::collections::HashMap;
use specs::prelude::*;
use crate::components::*;
use super::{Raws};
impl RawMaster {
pub fn empty() -> RawMaster {
RawMaster {
raws : Raws{ items: Vec::new() },
item_index : HashMap::new()
}
}
That's very straightforward, and well within what we've learned of Rust so far: we
make a structure called RawMaster , it gets a private copy of the Raws data and a
HashMap storing item names and their index inside Raws.items . The empty
constructor does just that: it makes a completely empty version of the RawMaster
structure. load takes the de-serialized Raws structure, stores it, and indexes the
items by name and location in the items array.
but we'll be good "Rustaceans" and use a popular method: the lazy_static . This
functionality isn't part of the language itself, so we need to add a crate to cargo.toml
. Add the following line to your [dependencies] in the �le:
lazy_static = "1.4.0"
Now we do a bit of a dance to make the global safely available from everywhere. At
the end of main.rs 's import section, add:
#[macro_use]
extern crate lazy_static;
This is similar to what we've done for other macros: it tells Rust that we'd like to
import the macros from the crate lazy_static . In mod.rs , declare the following:
mod rawmaster;
pub use rawmaster::*;
use std::sync::Mutex;
Also:
lazy_static! {
pub static ref RAWS : Mutex<RawMaster> =
Mutex::new(RawMaster::empty());
}
The lazy_static! macro does a bunch of hard work for us to make this safe. The
interesting part is that we still have to use a Mutex . Mutexes are a construct that
ensure that no more than one thread at a time can write to a structure. You access a
Mutex by calling lock - it is now yours until the lock goes out of scope. So in our
load_raws function, we need to populate it:
RAWS.lock().unwrap().load(decoder);
You'll notice that RLTK's embedding system is quietly using a lazy_static itself -
that's what the lock and unwrap code is for: it manages the Mutex. So for our RAWS
global, we lock it (retrieving a scoped lock), unwrap that lock (to allow us to access
the contents), and call the load function we wrote earlier. Quite a mouthful, but now
we can safely share the RAWS data without having to worry about threading
problems. Once loaded, we'll probably never write to it again - and Mutex locks for
reading are pretty much instantaneous when you don't have lots of threads running.
// Renderable
if let Some(renderable) = &item_template.renderable {
eb = eb.with(crate::components::Renderable{
glyph:
rltk::to_cp437(renderable.glyph.chars().next().unwrap()),
fg : rltk::RGB::from_hex(&renderable.fg).expect("Invalid
RGB"),
bg : rltk::RGB::from_hex(&renderable.bg).expect("Invalid
RGB"),
render_order : renderable.order
});
}
eb = eb.with(crate::components::Item{});
return Some(eb.build());
}
None
}
It's a long function, but it's actually very straightforward - and uses patterns we've
encountered plenty of times before. It does the following:
1. It looks to see if the key we've passed exists in the item_index . If it doesn't, it
returns None - it didn't do anything.
2. If the key does exist, then it adds a Name component to the entity - with the
name from the raw �le.
3. If Renderable exists in the item de�nition, it creates a component of type
Renderable .
4. If Consumable exists in the item de�nition, it makes a new consumable. It
iterates through all of the keys/values inside the effect dictionary, adding
e�ect components as needed.
match spawn.1.as_ref() {
"Goblin" => goblin(ecs, x, y),
"Orc" => orc(ecs, x, y),
"Fireball Scroll" => fireball_scroll(ecs, x, y),
"Confusion Scroll" => confusion_scroll(ecs, x, y),
"Dagger" => dagger(ecs, x, y),
"Shield" => shield(ecs, x, y),
"Longsword" => longsword(ecs, x, y),
"Tower Shield" => tower_shield(ecs, x, y),
"Rations" => rations(ecs, x, y),
"Magic Mapping Scroll" => magic_mapping_scroll(ecs, x, y),
"Bear Trap" => bear_trap(ecs, x, y),
"Door" => door(ecs, x, y),
_ => {}
}
}
Note that we've deleted the items we've added into spawns.json . We can also delete
the associated functions. spawner.rs will be really small when we're done! So the
magic here is that it calls spawn_named_item , using a rather ugly
&RAWS.lock().unwrap() to obtain safe access to our RAWS global variable. If it
matched a key, it will return Some(Entity) - otherwise, we get None . So we check if
item_result.is_some() and return if we succeeded in spawning something from the
data. Otherwise, we use the new code.
You'll also want to add a raws::* to the list of items imported from super .
If you cargo run now, the game runs as before - including health potions and magic
missile scrolls.
...
{
"name" : "Fireball Scroll",
"renderable": {
"glyph" : ")",
"fg" : "#FFA500",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : {
"ranged" : "6",
"damage" : "20",
"area_of_effect" : "3"
}
}
},
{
"name" : "Confusion Scroll",
"renderable": {
"glyph" : ")",
"fg" : "#FFAAAA",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : {
"ranged" : "6",
"damage" : "20",
"confusion" : "4"
}
}
},
{
"name" : "Magic Mapping Scroll",
"renderable": {
"glyph" : ")",
"fg" : "#AAAAFF",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : {
"magic_mapping" : ""
}
}
},
{
"name" : "Rations",
"renderable": {
"glyph" : "%",
"fg" : "#00FF00",
"bg" : "#000000",
"order" : 2
},
"consumable" : {
"effects" : {
"food" : ""
}
}
}
]
}
You can now delete the �reball, magic mapping and confusion scrolls from
spawner.rs ! Run the game, and you have access to these items. Hopefully, this is
starting to illustrate the power of linking a data �le to your component creation.
{
"name" : "Dagger",
"renderable": {
"glyph" : "/",
"fg" : "#FFAAAA",
"bg" : "#000000",
"order" : 2
},
"weapon" : {
"range" : "melee",
"power_bonus" : 2
}
},
{
"name" : "Longsword",
"renderable": {
"glyph" : "/",
"fg" : "#FFAAFF",
"bg" : "#000000",
"order" : 2
},
"weapon" : {
"range" : "melee",
"power_bonus" : 4
}
},
{
"name" : "Shield",
"renderable": {
"glyph" : "[",
"fg" : "#00AAFF",
"bg" : "#000000",
"order" : 2
},
"shield" : {
"defense_bonus" : 1
}
},
{
"name" : "Tower Shield",
"renderable": {
"glyph" : "[",
"fg" : "#00FFFF",
"bg" : "#000000",
"order" : 2
},
"shield" : {
"defense_bonus" : 3
}
}
There are two new �elds here! shield and weapon . We need to expand our
item_structs.rs to handle them:
#[derive(Deserialize, Debug)]
pub struct Item {
pub name : String,
pub renderable : Option<Renderable>,
pub consumable : Option<Consumable>,
pub weapon : Option<Weapon>,
pub shield : Option<Shield>
}
...
#[derive(Deserialize, Debug)]
pub struct Weapon {
pub range: String,
pub power_bonus: i32
}
#[derive(Deserialize, Debug)]
pub struct Shield {
pub defense_bonus: i32
}
We'll also have to teach our spawn_named_item function (in rawmaster.rs ) to use this
data:
You can now delete these items from spawner.rs as well, and they still spawn in
game - as before.
"mobs" : [
{
"name" : "Orc",
"renderable": {
"glyph" : "o",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 8
},
{
"name" : "Goblin",
"renderable": {
"glyph" : "g",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 8,
"hp" : 8,
"defense" : 1,
"power" : 3
},
"vision_range" : 8
}
]
You'll notice that we're �xing a minor issue from before: orcs and goblins are no
longer identical in stats! Otherwise, this should make sense: the stats we set in
spawner.rs are instead set in the JSON �le. We need to create a new �le,
raws/mob_structs.rs :
use serde::{Deserialize};
use super::{Renderable};
#[derive(Deserialize, Debug)]
pub struct Mob {
pub name : String,
pub renderable : Option<Renderable>,
pub blocks_tile : bool,
pub stats : MobStats,
pub vision_range : i32
}
#[derive(Deserialize, Debug)]
pub struct MobStats {
pub max_hp : i32,
pub hp : i32,
pub power : i32,
pub defense : i32
}
We'll also modify Raws (currently in item_structs.rs ). We'll move it to mod.rs , since
it is shared with other modules and edit it:
#[derive(Deserialize, Debug)]
pub struct Raws {
pub items : Vec<Item>,
pub mobs : Vec<Mob>
}
We also need to modify rawmaster.rs to add an empty mobs list to the constructor:
impl RawMaster {
pub fn empty() -> RawMaster {
RawMaster {
raws : Raws{ items: Vec::new(), mobs: Vec::new() },
item_index : HashMap::new()
}
}
...
impl RawMaster {
pub fn empty() -> RawMaster {
RawMaster {
raws : Raws{ items: Vec::new(), mobs: Vec::new() },
item_index : HashMap::new(),
mob_index : HashMap::new()
}
}
We're going to want to build a spawn_named_mob function, but �rst lets create some
helpers so we're sharing functionality with spawn_named_item - avoid repeating
ourselves. The �rst is pretty straightforward:
eb
}
When we add more SpawnType entries, this function will necessarily expand to
include them - so it's great that it's a function. We can replace the same code in
spawn_named_item with a single call to this function:
Let's also break out handling of Renderable data. This was more di�cult; I had a
terrible time getting Rust's lifetime checker to work with a system that actually added
it to the EntityBuilder . I �nally settled on a function that returns the component for
the caller to add:
fn get_renderable_component(renderable : &super::item_structs::Renderable)
-> crate::components::Renderable {
crate::components::Renderable{
glyph: rltk::to_cp437(renderable.glyph.chars().next().unwrap()),
fg : rltk::RGB::from_hex(&renderable.fg).expect("Invalid RGB"),
bg : rltk::RGB::from_hex(&renderable.bg).expect("Invalid RGB"),
render_order : renderable.order
}
}
// Renderable
if let Some(renderable) = &item_template.renderable {
eb = eb.with(get_renderable_component(renderable));
}
// Renderable
if let Some(renderable) = &mob_template.renderable {
eb = eb.with(get_renderable_component(renderable));
}
eb = eb.with(Monster{});
if mob_template.blocks_tile {
eb = eb.with(BlocksTile{});
}
eb = eb.with(CombatStats{
max_hp : mob_template.stats.max_hp,
hp : mob_template.stats.hp,
power : mob_template.stats.power,
defense : mob_template.stats.defense
});
eb = eb.with(Viewshed{ visible_tiles : Vec::new(), range:
mob_template.vision_range, dirty: true });
return Some(eb.build());
}
None
}
There's really nothing we haven't already covered in this function: we simply apply a
renderable, position, name using the same code as before - and then check
blocks_tile to see if we should add a BlocksTile component, and copy the stats
into a CombatStats component. We also setup a Viewshed component with
vision_range range.
None
}
We can also go ahead and delete the references to Orcs, Goblins and Monsters! We're
nearly there - you can get your data-driven monsters now.
"props" : [
{
"name" : "Bear Trap",
"renderable": {
"glyph" : "^",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 2
},
"hidden" : true,
"entry_trigger" : {
"effects" : {
"damage" : "6",
"single_activation" : "1"
}
}
},
{
"name" : "Door",
"renderable": {
"glyph" : "+",
"fg" : "#805A46",
"bg" : "#000000",
"order" : 2
},
"hidden" : false,
"blocks_tile" : true,
"blocks_visibility" : true,
"door_open" : true
}
]
The problem with props is that they can be really quite varied, so we end up with a lot
of optional stu� in the de�nition. I'd rather have a complex de�nition on the Rust side
than on the JSON side, to reduce the sheer volume of typing when we have a lot of
props. So we wind up making something reasonably expressive in JSON, and do a lot
of work to make it function in Rust! We'll make a new �le, prop_structs.rs and put
our serialization classes into it:
use serde::{Deserialize};
use super::{Renderable};
use std::collections::HashMap;
#[derive(Deserialize, Debug)]
pub struct Prop {
pub name : String,
pub renderable : Option<Renderable>,
pub hidden : Option<bool>,
pub blocks_tile : Option<bool>,
pub blocks_visibility : Option<bool>,
pub door_open : Option<bool>,
pub entry_trigger : Option<EntryTrigger>
}
#[derive(Deserialize, Debug)]
pub struct EntryTrigger {
pub effects : HashMap<String, String>
}
mod prop_structs;
use prop_structs::*;
#[derive(Deserialize, Debug)]
pub struct Raws {
pub items : Vec<Item>,
pub mobs : Vec<Mob>,
pub props : Vec<Prop>
}
That takes us into rawmaster.rs , where we need to extend the constructor and
reader to include the new types:
impl RawMaster {
pub fn empty() -> RawMaster {
RawMaster {
raws : Raws{ items: Vec::new(), mobs: Vec::new(), props:
Vec::new() },
item_index : HashMap::new(),
mob_index : HashMap::new(),
prop_index : HashMap::new()
}
}
// Renderable
if let Some(renderable) = &prop_template.renderable {
eb = eb.with(get_renderable_component(renderable));
}
return Some(eb.build());
}
None
}
We'll gloss over the contents because this is basically the same as what we've done
before. We need to extend spawn_named_entity to include props:
None
}
Finally, we can go into spawner.rs and remove the door and bear trap functions. We
can �nish cleaning up the spawn_entity function. We're also going to add a warning
in case you try to spawn something we don't know about:
If you cargo run now, you'll see doors and traps working as before.
Wrap-Up
This chapter has given us the ability to easily change the items, mobs and props that
adorn our levels. We haven't touched adding more yet (or adjusting the spawn tables) -
that'll be the next chapter. You can quickly change the character of the game now;
want Goblins to be weaker? Lower their stats! Want them to have better eyesight than
Orcs? Adjust their vision range! That's the primary bene�t of a data-driven approach:
you can quickly make changes without having to dive into source code. The engine
becomes responsible for simulating the world - and the data becomes responsible for
describing the world.
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.
If you look at the ever-shrinking spawner.rs �le, we have a hard-coded table for
handling spawning:
It's served us well for all these chapters, but sadly it's time to put it out to pasture.
We'd like to be able to specify the spawn table in our JSON data - that way, we can add
new entities to the data �le and spawn list, and they appear in the game with no
additional Rust coding (unless they need new features, in which case it's time to
extend the engine).
"spawn_table" : [
{ "name" : "Goblin", "weight" : 10, "min_depth" : 0, "max_depth" : 100
}
],
So the spawn_table is an array, with each entry containing something that can be
spawned. We're storing the name of the spawnable. We give it a weight, which
corresponds to the same �eld in our current RandomTable structure. We've added a
min_depth and max_depth - so this spawn line will only apply to a speci�ed depth
range of the dungeon.
That looks pretty good, so lets put all of our entities in:
"spawn_table" : [
{ "name" : "Goblin", "weight" : 10, "min_depth" : 0, "max_depth" : 100
},
{ "name" : "Orc", "weight" : 1, "min_depth" : 0, "max_depth" : 100,
"add_map_depth_to_weight" : true },
{ "name" : "Health Potion", "weight" : 7, "min_depth" : 0, "max_depth"
: 100 },
{ "name" : "Fireball Scroll", "weight" : 2, "min_depth" : 0,
"max_depth" : 100, "add_map_depth_to_weight" : true },
{ "name" : "Confusion Scroll", "weight" : 2, "min_depth" : 0,
"max_depth" : 100, "add_map_depth_to_weight" : true },
{ "name" : "Magic Missile Scroll", "weight" : 4, "min_depth" : 0,
"max_depth" : 100 },
{ "name" : "Dagger", "weight" : 3, "min_depth" : 0, "max_depth" : 100
},
{ "name" : "Shield", "weight" : 3, "min_depth" : 0, "max_depth" : 100
},
{ "name" : "Longsword", "weight" : 1, "min_depth" : 1, "max_depth" :
100 },
{ "name" : "Tower Shield", "weight" : 1, "min_depth" : 1, "max_depth"
: 100 },
{ "name" : "Rations", "weight" : 10, "min_depth" : 0, "max_depth" :
100 },
{ "name" : "Magic Mapping Scroll", "weight" : 2, "min_depth" : 0,
"max_depth" : 100 },
{ "name" : "Bear Trap", "weight" : 5, "min_depth" : 0, "max_depth" :
100 }
],
That's pretty comprehensive (covers everything we have so far, and adds some
capability), so lets make a new �le spawn_table_structs in raws and de�ne the
classes required to read this data:
use serde::{Deserialize};
use super::{Renderable};
#[derive(Deserialize, Debug)]
pub struct SpawnTableEntry {
pub name : String,
pub weight : i32,
pub min_depth: i32,
pub max_depth: i32,
pub add_map_depth_to_weight : Option<bool>
}
mod spawn_table_structs;
use spawn_table_structs::*;
...
#[derive(Deserialize, Debug)]
pub struct Raws {
pub items : Vec<Item>,
pub mobs : Vec<Mob>,
pub props : Vec<Prop>,
pub spawn_table : Vec<SpawnTableEntry>
}
It's worth doing a quick cargo run now, just to be sure that the spawn table is
loading without errors. It won't do anything yet, but it's always good to know that the
data loads properly.
rt
}
Wow, that's a short function! It does the job, however. If you cargo run now, you'll be
playing the game like before.
These types of data-entry bugs are common, and won't actually crash the program.
This sanity check ensures that we are at least warned about it before we proceed
thinking that all is well. If you're paranoid (when programming, that's actually a good
trait; there are plenty of people who are out to get you!), you could replace the
println! with panic! and crash instead of just reminding the user. You may not
want to do that if you like to cargo run often to see how you are doing!
{
"name" : "Battleaxe",
"renderable": {
"glyph" : "¶",
"fg" : "#FF55FF",
"bg" : "#000000",
"order" : 2
},
"weapon" : {
"range" : "melee",
"power_bonus" : 5
}
},
Let's also add a humble kobold. It's basically an even weaker goblin. We like kobolds,
lets have lots of them!
{
"name" : "Kobold",
"renderable": {
"glyph" : "k",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 4,
"hp" : 4,
"defense" : 0,
"power" : 2
},
"vision_range" : 4
}
Notice that we make them really common - and stop harassing the player with them
after level 3.
If you cargo run the project now, you'll �nd the new entities in the game:
Wrap-Up
That's it for spawn tables! You've gained considerable power in these last two
chapters - use it wisely. You can add in all manner of entities without having to write a
line of Rust now, and could easily start to shape the game to what you want. In the
next chapter, we'll begin doing just that.
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.
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 o�ered 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 �rst 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.
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.
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 �gures, visible at least as Guards or Watch.
Towns also generally have a shady side.
content, talk to these guys, and o� you go" speed-bump start to an amazing game.
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 �le - tiletype.rs :
And in mod.rs we'll accept the module and share the public types it exposes:
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 �le makes it
easier to �nd the relevant code:
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 di�erent
cost for path-�nding, and so on. We've also done a lot of "spawn if its a �oor" code in
our map builders; maybe that wasn't such a good idea if you can have multiple �oor
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:
Now we'll go back into mod.rs , and import these - and make them public to anyone
who wants them:
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-�nding with the blocked system, so we need to update
populate_blocked to handle the various types using the functions we just made:
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
�oor and a wall to choose from, it is a pretty easy choice after all! Once we start
o�ering 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
de�nitely more realistic if they avoid standing in shallow water unless they need to. So
we'll build a cost function (in tiletype.rs ):
// 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.
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
}
Pop over to main.rs and change the builder function call to use our new function:
Now, we'll start �eshing 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:
mod town;
use town::town_builder;
use super::BuilderChain;
impl TownBuilder {
pub fn new() -> Box<TownBuilder> {
Box::new(TownBuilder{})
}
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:
// 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();
}
1. We make n equal to a random �oating point number between 0.0 and 1.0 by
rolling a 65,535 sided dice (wouldn't it be nice if one of those existed?) and
dividing by the maximum number.
2. 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.
3. For each y row down the map:
1. We make n_water . This is the number of water tiles present. We start by
taking the sin (Sine) of n (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. Since sin 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.
2. We push this into the water_width vector, storing it for later.
3. We add 0.1 to n , progressing along the sine wave.
4. Then we iterate from 0 to n_water (as x ) and write DeepWater tiles to the
position of each water tile.
5. We go from n_water to n_water+3 to add some shallow water at the
edge.
4. We take a snapshot so you can watch the map progression.
5. We iterate from 0 to 1d4+6 to generate between 10 and 14 piers.
1. We pick y at random.
2. We look up the water placement for that y value, and draw wooden �oors
starting at 2+1d6 to water_width[y]+4 - giving a pier that extends out into
the water for some way, and ends squarely on land.
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)
}
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:
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
}
1. We make a vector of tuples, each containing 4 integers. These are the building's
x and y coordinates, along with its size in each dimension.
2. We make a variable n_buildings to store how many we've placed, and loop
until we have 12. For each building:
1. We pick a random x and y position, and a random width and height
for the building.
2. We set possible to true - and then loop over every tile in the candidate
building location. If it isn't in the available_building_tiles set, we set
possible to false .
3. If possible is still true, we again loop over every tile - setting to be a
WoodenFloor . We then remove that tile, and all four surrounding tiles from
the available_building_tiles list - ensuring a gap between buildings. We
also increment n_buildings , and add the building to a list of completed
buildings.
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
}
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:
self.add_paths(build_data, &doors);
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());
1. 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.
2. Then we iterate through all the doors we've placed:
1. We make another vector ( nearest_roads ) containing an index and a �oat.
2. We add each road, with its index and the calculated distance to the door.
3. We sort the nearest_roads vector by the distances, ensuring that element
0 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.
4. We call RLTK's a star pathing to �nd a route from the door to the nearest
road.
5. 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 in�uence future paths.
If you cargo run now, you'll see a pretty decent start for a town:
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:
Placing the entrance is more di�cult. 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 �rst tuple
element being the building's index and the second being it's "square tileage"):
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 .
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 �shing 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.
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.
In the previous chapter, we built the layout of our town. In this chapter, we'll populate
it with NPCs and Props. We'll introduce some new AI types to handle friendly or
neutral NPCs, and begin placing merchants, townsfolk and other residents to make
the town come alive. We'll also begin placing furniture and items to make the place
feel less barren.
The Pub.
The Temple.
That leaves 10 other locations that aren't really relevant, but we've implied that they
will include vendors. Brainstorming a few vendors, it would make sense to have:
So we're down to 5 more locations to �ll! Lets make three of them into regular homes
with residents, one into your house - complete with a nagging mother, and one into
an abandoned house with a rodent issue. Rodent problems are a staple of fantasy
games, and it might make for a good tutorial when we get that far.
You'll remember that we sorted our buildings by size, and decided that the largest is
the pub. Let's extend that to tag each building. In map_builders/town.rs , look at the
build function and we'll expand the building sorter. First, lets make an enum for our
building types:
enum BuildingTag {
Pub, Temple, Blacksmith, Clothier, Alchemist, PlayerHouse, Hovel,
Abandoned, Unassigned
}
Next, we'll move our building sorter code into its own function (as part of
TownBuilder ):
This is the code we had before, with added BuildingTag entries. Once we've sorted
by size, we assign the various building types - with the last one always being the
abandoned house. This will ensure that we have all of our building types, and they are
sorted in descending size order.
In the build function, replace your sort code with a call to the function - and a call to
building_factory , which we'll write in a moment:
fn building_factory(&mut self,
rng: &mut rltk::RandomNumberGenerator,
build_data : &mut BuilderMap,
buildings: &[(i32, i32, i32, i32)],
building_index : &[(usize, i32, BuildingTag)])
{
for (i,building) in buildings.iter().enumerate() {
let build_type = &building_index[i].2;
match build_type {
_ => {}
}
}
}
The Pub
So what would you expect to �nd in a pub early in the morning, when you awaken
hung-over and surprised to discover that you've promised to save the world? A few
ideas spring to mind:
We'll extend our factory function to have a match line to build the pub:
fn building_factory(&mut self,
rng: &mut rltk::RandomNumberGenerator,
build_data : &mut BuilderMap,
buildings: &[(i32, i32, i32, i32)],
building_index : &[(usize, i32, BuildingTag)])
{
for (i,building) in buildings.iter().enumerate() {
let build_type = &building_index[i].2;
match build_type {
BuildingTag::Pub => self.build_pub(&building, build_data,
rng),
_ => {}
}
}
}
fn build_pub(&mut self,
building: &(i32, i32, i32, i32),
build_data : &mut BuilderMap,
rng: &mut rltk::RandomNumberGenerator)
{
// Place the player
build_data.starting_position = Some(Position{
x : building.0 + (building.2 / 2),
y : building.1 + (building.3 / 2)
});
let player_idx = build_data.map.xy_idx(building.0 + (building.2 / 2),
building.1 + (building.3 / 2));
1. The function takes our building data, map information and random number
generator as parameters.
2. Since we always start the player in the pub, we do that here. We can remove it
from the build function.
3. We store the player_idx - we don't want to spawn anything on top of the
player.
4. We make to_place - a list of string tags that we want in the bar. We'll worry
about writing these in a bit.
5. We iterate x and y across the whole building.
1. We calculate the map index of the building tile.
2. If the building tile is a wooden �oor, the map index is not the player map
index, and a 1d3 roll comes up 1, we:
1. Take the �rst tag from the to_place list, and remove it from the list
(no duplicates unless we put it in twice).
2. Add that tag to the spawn_list for the map, using the current tile
tag.
That's pretty simple, and also parts are de�nitely generic enough to help with future
buildings. If you were to run the project now, you'll see error messages such as:
WARNING: We don't know how to spawn [Barkeep]! . That's because we haven't
written them, yet. We need spawns.json to include all of the tags we're trying to
spawn.
Let's add an entry into spawns.json for our Barkeep. We'll introduce one new
element - the ai :
"mobs" : [
{
"name" : "Barkeep",
"renderable": {
"glyph" : "☺",
"fg" : "#EE82EE",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "bystander"
},
#[derive(Deserialize, Debug)]
pub struct Mob {
pub name : String,
pub renderable : Option<Renderable>,
pub blocks_tile : bool,
pub stats : MobStats,
pub vision_range : i32,
pub ai : String
}
We'll also need to add "ai" : "melee" to each other mob. Now open
raws/rawmaster.rs , and we'll edit spawn_named_mob to support it. Replace the line
eb = eb.with(Monster{}); with:
match mob_template.ai.as_ref() {
"melee" => eb = eb.with(Monster{}),
"bystander" => eb = eb.with(Bystander{}),
_ => {}
}
If you cargo run now, you should see a smiling barkeep. He's resplendent in Purple
(RGB #EE82EE from the JSON). Why purple? We're going to make vendors purple
eventually (vendors are for a future chapter):
He won't react to you or do anything, but he's there. We'll add some behavior later in
the chapter. For now, lets go ahead and add some other entities to spawns.json now
that we support innocent bystanders (pro-tip: copy an existing entry and edit it; much
easier than typing it all out again):
{
"name" : "Shady Salesman",
"renderable": {
"glyph" : "h",
"fg" : "#EE82EE",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "bystander"
},
{
"name" : "Patron",
"renderable": {
"glyph" : "☺",
"fg" : "#AAAAAA",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "bystander"
},
If you cargo run now, the bar comes to life a bit more:
Adding props
A pub with people and nothing for them to drink, sit on or eat at is a pretty shabby
pub. I suppose we could argue that it's a real dive and the budget won't stretch to
that, but that argument wears thin when you start adding other buildings. So we'll add
some props to spawns.json :
{
"name" : "Keg",
"renderable": {
"glyph" : "φ",
"fg" : "#AAAAAA",
"bg" : "#000000",
"order" : 2
},
"hidden" : false
},
{
"name" : "Table",
"renderable": {
"glyph" : "╦",
"fg" : "#AAAAAA",
"bg" : "#000000",
"order" : 2
},
"hidden" : false
},
{
"name" : "Chair",
"renderable": {
"glyph" : "└",
"fg" : "#AAAAAA",
"bg" : "#000000",
"order" : 2
},
"hidden" : false
}
If you cargo run now, you'll see some inert props littering the pub:
fn random_building_spawn(
&mut self,
building: &(i32, i32, i32, i32),
build_data : &mut BuilderMap,
rng: &mut rltk::RandomNumberGenerator,
to_place : &mut Vec<&str>,
player_idx : usize)
{
for y in building.1 .. building.1 + building.3 {
for x in building.0 .. building.0 + building.2 {
let idx = build_data.map.xy_idx(x, y);
if build_data.map.tiles[idx] == TileType::WoodFloor && idx !=
player_idx && rng.roll_dice(1, 3)==1 && !to_place.is_empty() {
let entity_tag = to_place[0];
to_place.remove(0);
build_data.spawn_list.push((idx, entity_tag.to_string()));
}
}
}
}
With that in place, let's think about what you might �nd in a temple:
Priests
Parishioners
Chairs
Candles
match build_type {
BuildingTag::Pub => self.build_pub(&building, build_data, rng),
BuildingTag::Temple => self.build_temple(&building, build_data, rng),
_ => {}
}
fn build_temple(&mut self,
building: &(i32, i32, i32, i32),
build_data : &mut BuilderMap,
rng: &mut rltk::RandomNumberGenerator)
{
// Place items
let mut to_place : Vec<&str> = vec!["Priest", "Parishioner",
"Parishioner", "Chair", "Chair", "Candle", "Candle"];
self.random_building_spawn(building, build_data, rng, &mut to_place,
0);
}
So, with that in place - we still have to add Priests, Parishioners, and Candles to the
spawns.json list. The Priest and Parishioner go in the mobs section, and are basically
the same as the Barkeep:
{
"name" : "Priest",
"renderable": {
"glyph" : "☺",
"fg" : "#EE82EE",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "bystander"
},
{
"name" : "Parishioner",
"renderable": {
"glyph" : "☺",
"fg" : "#AAAAAA",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "bystander"
},
{
"name" : "Candle",
"renderable": {
"glyph" : "Ä",
"fg" : "#FFA500",
"bg" : "#000000",
"order" : 2
},
"hidden" : false
}
If you cargo run now, you can run around and �nd a temple:
We're lumping these in together because they are basically the same function! Here's
the body of each of them:
fn build_smith(&mut self,
building: &(i32, i32, i32, i32),
build_data : &mut BuilderMap,
rng: &mut rltk::RandomNumberGenerator)
{
// Place items
let mut to_place : Vec<&str> = vec!["Blacksmith", "Anvil", "Water
Trough", "Weapon Rack", "Armor Stand"];
self.random_building_spawn(building, build_data, rng, &mut to_place,
0);
}
fn build_clothier(&mut self,
building: &(i32, i32, i32, i32),
build_data : &mut BuilderMap,
rng: &mut rltk::RandomNumberGenerator)
{
// Place items
let mut to_place : Vec<&str> = vec!["Clothier", "Cabinet", "Table",
"Loom", "Hide Rack"];
self.random_building_spawn(building, build_data, rng, &mut to_place,
0);
}
fn build_alchemist(&mut self,
building: &(i32, i32, i32, i32),
build_data : &mut BuilderMap,
rng: &mut rltk::RandomNumberGenerator)
{
// Place items
let mut to_place : Vec<&str> = vec!["Alchemist", "Chemistry Set",
"Dead Thing", "Chair", "Table"];
self.random_building_spawn(building, build_data, rng, &mut to_place,
0);
}
fn build_my_house(&mut self,
building: &(i32, i32, i32, i32),
build_data : &mut BuilderMap,
rng: &mut rltk::RandomNumberGenerator)
{
// Place items
let mut to_place : Vec<&str> = vec!["Mom", "Bed", "Cabinet", "Chair",
"Table"];
self.random_building_spawn(building, build_data, rng, &mut to_place,
0);
}
fn build_hovel(&mut self,
building: &(i32, i32, i32, i32),
build_data : &mut BuilderMap,
rng: &mut rltk::RandomNumberGenerator)
{
// Place items
let mut to_place : Vec<&str> = vec!["Peasant", "Bed", "Chair",
"Table"];
self.random_building_spawn(building, build_data, rng, &mut to_place,
0);
}
As you can see - these are basically passing spawn lists to the building spawner, rather
than doing anything too fancy. We've created quite a lot of new entities here! I tried to
come up with things you might �nd in each location:
The smith has of course got a Blacksmith. He likes to be around Anvils, Water
Troughs, Weapon Racks, and Armor Stands.
The clothier has a Clothier, and a Cabinet, a Table, a Loom and a Hide Rack.
The alchemist has an Alchemist, a Chemistry Set, a Dead Thing (why not, right?), a
Chair and a Table.
My House features Mom (the characters mother!), a bed, a cabinet, a chair and a
table.
Hovels feature a Peasant, a bed, a chair and a table.
{
"name" : "Blacksmith",
"renderable": {
"glyph" : "☺",
"fg" : "#EE82EE",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "bystander"
},
{
"name" : "Clothier",
"renderable": {
"glyph" : "☺",
"fg" : "#EE82EE",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "bystander"
},
{
"name" : "Alchemist",
"renderable": {
"glyph" : "☺",
"fg" : "#EE82EE",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "bystander"
},
{
"name" : "Mom",
"renderable": {
"glyph" : "☺",
"fg" : "#FFAAAA",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "bystander"
},
{
"name" : "Peasant",
"renderable": {
"glyph" : "☺",
"fg" : "#999999",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "bystander"
},
{
"name" : "Anvil",
"renderable": {
"glyph" : "╔",
"fg" : "#AAAAAA",
"bg" : "#000000",
"order" : 2
},
"hidden" : false
},
{
"name" : "Water Trough",
"renderable": {
"glyph" : "•",
"fg" : "#5555FF",
"bg" : "#000000",
"order" : 2
},
"hidden" : false
},
{
"name" : "Weapon Rack",
"renderable": {
"glyph" : "π",
"fg" : "#FFD700",
"bg" : "#000000",
"order" : 2
},
"hidden" : false
},
{
"name" : "Armor Stand",
"renderable": {
"glyph" : "⌠",
"fg" : "#FFFFFF",
"bg" : "#000000",
"order" : 2
},
"hidden" : false
},
{
"name" : "Chemistry Set",
"renderable": {
"glyph" : "δ",
"fg" : "#00FFFF",
"bg" : "#000000",
"order" : 2
},
"hidden" : false
},
{
"name" : "Dead Thing",
"renderable": {
"glyph" : "☻",
"fg" : "#AA0000",
"bg" : "#000000",
"order" : 2
},
"hidden" : false
},
{
"name" : "Cabinet",
"renderable": {
"glyph" : "∩",
"fg" : "#805A46",
"bg" : "#000000",
"order" : 2
},
"hidden" : false
},
{
"name" : "Bed",
"renderable": {
"glyph" : "8",
"fg" : "#805A46",
"bg" : "#000000",
"order" : 2
},
"hidden" : false
},
{
"name" : "Loom",
"renderable": {
"glyph" : "≡",
"fg" : "#805A46",
"bg" : "#000000",
"order" : 2
},
"hidden" : false
},
{
"name" : "Hide Rack",
"renderable": {
"glyph" : "π",
"fg" : "#805A46",
"bg" : "#000000",
"order" : 2
},
"hidden" : false
}
If you cargo run now, you can run around and �nd largely populated rooms:
Hopefully, you also spot the bug: the player beat his/her Mom (and the alchemist)! We
don't really want to encourage that type of behavior! So in the next segment, we'll
work on some neutral AI and player movement behavior with NPCs.
Neutral AI/Movement
There are two issues present with our current "bystander" handling: bystanders just
stand there like lumps (blocking your movement, even!), and there is no way to get
around them without slaughtering them. I'd like to think our hero won't start his/her
adventure by murdering their Mom - so lets rectify the situation!
Trading Places
Currently, when you "bump" into a tile containing anything with combat stats - you
launch an attack. This is provided in player.rs , the try_move_player function:
We need to extend this to not only attack, but swap places with the NPC when we
bump into them. This way, they can't block your movement - but you also can't
murder your mother! So �rst, we need to gain access to the Bystanders component
store, and make a vector in which we will store our intent to move NPCs (we can't just
access them in-loop; the borrow checker will throw a �t, unfortunately):
So in swap_entities , we're storing the entity to move and their x/y destination
coordinates. Now we adjust our main loop to check to see if a target is a bystander,
add them to the swap list and move anyway if they are. We also make attacking
conditional upon them not being a bystander:
viewshed.dirty = true;
let mut ppos = ecs.write_resource::<Point>();
ppos.x = pos.x;
ppos.y = pos.y;
} else {
let target = combat_stats.get(*potential_target);
if let Some(_target) = target {
wants_to_melee.insert(entity, WantsToMelee{ target:
*potential_target }).expect("Add target failed");
return;
}
}
Finally, at the very end of the function we iterate through swap_entities and apply
the movement:
for m in swap_entities.iter() {
let their_pos = positions.get_mut(m.0);
if let Some(their_pos) = their_pos {
their_pos.x = m.1;
their_pos.y = m.2;
}
}
If you cargo run now, you can no longer murder all of the NPCs; bumping into them
swaps your positions:
And here's the function to about half-�ll the house with rodents:
fn build_abandoned_house(&mut self,
building: &(i32, i32, i32, i32),
build_data : &mut BuilderMap,
rng: &mut rltk::RandomNumberGenerator)
{
for y in building.1 .. building.1 + building.3 {
for x in building.0 .. building.0 + building.2 {
let idx = build_data.map.xy_idx(x, y);
if build_data.map.tiles[idx] == TileType::WoodFloor && idx !=
0 && rng.roll_dice(1, 2)==1 {
build_data.spawn_list.push((idx, "Rat".to_string()));
}
}
}
}
{
"name" : "Rat",
"renderable": {
"glyph" : "r",
"fg" : "#FF0000",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 2,
"hp" : 2,
"defense" : 1,
"power" : 3
},
"vision_range" : 8,
"ai" : "melee"
},
If you cargo run now, and hunt around for the abandoned house - you'll �nd it full of
hostile rats:
Wrap-Up
In this chapter, we've added a bunch of props and bystanders to the town - as well as
a house full of angry rats. That makes it feel a lot more alive. It's by no means done
yet, but it's already starting to feel like the opening scene of a fantasy game. In the
next chapter, we're going to make some AI adjustments to make it feel more alive -
and add some bystanders who aren't conveniently hanging around inside buildings.
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.
I'd like to suggest dark incantations and candles to breathe life into NPCs, but in
reality - it's more code. We don't want our bystanders to stand around, dumb as rocks
anymore. They don't have to behave particularly sensibly, but it would be good if they
at least roam around a bit (other than vendors, that gets annoying - "where did the
blacksmith go?") and tell you about their day.
Now we'll adjust our raw �les ( spawns.json ); all of the merchants who feature
"ai" : "bystander" need to be changed to "ai" : "vendor" . So we'll change it for
our Barkeep, Alchemist, Clothier, Blacksmith and Shady Salesman.
match mob_template.ai.as_ref() {
"melee" => eb = eb.with(Monster{}),
"bystander" => eb = eb.with(Bystander{}),
"vendor" => eb = eb.with(Vendor{}),
_ => {}
}
Finally, we'll adjust the try_move_player function in player.rs to also not attack
vendors:
...
let vendors = ecs.read_storage::<Vendor>();
use specs::prelude::*;
use super::{Viewshed, Bystander, Map, Position, RunState, EntityMoved};
If you remember from the systems we've made before, the �rst part is boilerplate
telling the ECS what resources we want to access. We check to see if it is the monster's
turn (really, NPCs are monsters in this setup); if it isn't, we bail out. Then we roll a dice
for a random direction, see if we can go that way - and move if we can. It's pretty
simple!
impl State {
fn run_systems(&mut self) {
let mut mapindex = MapIndexingSystem{};
mapindex.run_now(&self.ecs);
let mut vis = VisibilitySystem{};
vis.run_now(&self.ecs);
let mut mob = MonsterAI{};
mob.run_now(&self.ecs);
let mut bystander = bystander_ai_system::BystanderAI{};
bystander.run_now(&self.ecs);
let mut triggers = trigger_system::TriggerSystem{};
triggers.run_now(&self.ecs);
let mut melee = MeleeCombatSystem{};
melee.run_now(&self.ecs);
let mut damage = DamageSystem{};
damage.run_now(&self.ecs);
let mut pickup = ItemCollectionSystem{};
pickup.run_now(&self.ecs);
let mut itemuse = ItemUseSystem{};
itemuse.run_now(&self.ecs);
let mut drop_items = ItemDropSystem{};
drop_items.run_now(&self.ecs);
let mut item_remove = ItemRemoveSystem{};
item_remove.run_now(&self.ecs);
let mut hunger = hunger_system::HungerSystem{};
hunger.run_now(&self.ecs);
let mut particles = particle_system::ParticleSpawnSystem{};
particles.run_now(&self.ecs);
self.ecs.maintain();
}
}
If you cargo run the project now, you can watch NPCs bumbling around randomly.
Having them move goes a long way to not making it feel like a town of statues!
Quipping NPCs
To further brings things to life, lets allow NPCs to "quip" when they spot you. In
spawns.json , lets add some quips to the Patron (bar patron):
{
"name" : "Patron",
"renderable": {
"glyph" : "☺",
"fg" : "#AAAAAA",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "bystander",
"quips" : [ "Quiet down, it's too early!", "Oh my, I drank too much.",
"Still saving the world, eh?" ]
},
#[derive(Deserialize, Debug)]
pub struct Mob {
pub name : String,
pub renderable : Option<Renderable>,
pub blocks_tile : bool,
pub stats : MobStats,
pub vision_range : i32,
pub ai : String,
pub quips : Option<Vec<String>>
}
Lastly, we'll add the ability to enter these quips into the game log when they spot you.
In bystander_ai_system.rs . First, extend the available set of data for the system as
follows:
...
WriteExpect<'a, rltk::RandomNumberGenerator>,
ReadExpect<'a, Point>,
WriteExpect<'a, GameLog>,
WriteStorage<'a, Quips>,
ReadStorage<'a, Name>);
You may remember this: it gets read-only access the the Point resource we store
containing the player's location, write access to the GameLog , and access to the
component stores for Quips and Name . Now, we add the quipping to the function
body:
...
for (entity, mut viewshed,_bystander,mut pos) in (&entities, &mut
viewshed, &bystander, &mut position).join() {
// Possibly quip
let quip = quips.get_mut(entity);
if let Some(quip) = quip {
if !quip.available.is_empty() &&
viewshed.visible_tiles.contains(&player_pos) && rng.roll_dice(1,6)==1 {
let name = names.get(entity);
let quip_index = if quip.available.len() == 1 { 0 } else {
(rng.roll_dice(1, quip.available.len() as i32)-1) as usize };
gamelog.entries.insert(0,
format!("{} says \"{}\"", name.unwrap().name,
quip.available[quip_index])
);
quip.available.remove(quip_index);
}
}
1. It asks for a component from the quips store. This will be an Option - either
None (nothing to say) or Some - containing the quips.
2. If there are some quips...
3. If the list of available quips isn't empty, the viewshed contains the player's tile,
and 1d6 roll comes up 1...
4. We look up the entity's name,
5. Randomly pick an entry in the available list from quip .
6. Log a string as Name says Quip .
7. Remove the quip from that entity's available quip list - they won't keep repeating
themselves.
If you run the game now, you'll �nd that patrons are willing to comment on life in
general:
We'll �nd that this can be used in other parts of the game, such as having guards
shouting alerts, or goblins saying appropriately "Goblinesque" things. For brevity, we
won't list every quip in the game here. Check out the source to see what we've added.
This sort of "�u�" goes a long way towards making a world feel alive, even if it doesn't
really add to gameplay in a meaningful fashion. Since the town is the �rst area the
player sees, it's good to have �u�.
Outdoor NPCs
All of the NPCs in the town so far have been conveniently located inside buildings. It
isn't very realistic, even in terrible weather (which we don't have!); so we should look
at spawning a few outdoor NPCs.
Open up map_builders/town.rs and we'll make two new functions; here's the calls to
them in the main build function:
self.spawn_dockers(build_data, rng);
self.spawn_townsfolk(build_data, rng, &mut available_building_tiles);
The spawn_dockers function looks for bridge tiles, and places various people on
them:
This is simple enough: for each tile on the map, retrieve its index and type. If its a
bridge, and a 1d6 comes up a 1 - spawn someone. We randomly pick between Dock
Workers, Wannabe Pirates and Fisherfolk.
fn spawn_townsfolk(&mut self,
build_data : &mut BuilderMap,
rng: &mut rltk::RandomNumberGenerator,
available_building_tiles : &mut HashSet<usize>)
{
for idx in available_building_tiles.iter() {
if rng.roll_dice(1, 10)==1 {
let roll = rng.roll_dice(1, 4);
match roll {
1 => build_data.spawn_list.push((*idx,
"Peasant".to_string())),
2 => build_data.spawn_list.push((*idx,
"Drunk".to_string())),
3 => build_data.spawn_list.push((*idx, "Dock
Worker".to_string())),
_ => build_data.spawn_list.push((*idx,
"Fisher".to_string())),
}
}
}
}
This iterates all the remaining availble_building_tiles ; these are tiles we know
won't be inside of a building, because we removed them when we placed buildings! So
each spot is guaranteed to be outdoors, and in town. For each tile, we roll 1d10 - and
if its a 1, we spawn one of a Peasant, a Drunk, a Dock Worker or a Fisher.
{
"name" : "Dock Worker",
"renderable": {
"glyph" : "☺",
"fg" : "#999999",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "bystander",
"quips" : [ "Lovely day, eh?", "Nice weather", "Hello" ]
},
{
"name" : "Fisher",
"renderable": {
"glyph" : "☺",
"fg" : "#999999",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "bystander",
"quips" : [ "They're biting today!", "I caught something, but it
wasn't a fish!", "Looks like rain" ]
},
{
"name" : "Wannabe Pirate",
"renderable": {
"glyph" : "☺",
"fg" : "#aa9999",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "bystander",
"quips" : [ "Arrr", "Grog!", "Booze!" ]
},
{
"name" : "Drunk",
"renderable": {
"glyph" : "☺",
"fg" : "#aa9999",
"bg" : "#000000",
"order" : 1
},
"blocks_tile" : true,
"stats" : {
"max_hp" : 16,
"hp" : 16,
"defense" : 1,
"power" : 4
},
"vision_range" : 4,
"ai" : "bystander",
"quips" : [ "Hic", "Need... more... booze!", "Spare a copper?" ]
},
If you cargo run now, you'll see a town teeming with life:
Wrap-Up
This chapter has really brought our town to life. There's always room for
improvement, but it's good enough for a starting map! The next chapter will change
gear, and start adding stats to the game.
Contributors
855 of 856 2019-11-02, 3:59 p.m.
Roguelike Tutorial - In Rust https://bfnightly.bracketproductions.com/rustbook...
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.
Supporters
I'd also like to take a moment to thank everyone who has sent me kind words,
contributed with issue reports, and the following Patrons (from patreon.com):
Ben Gamble
Noah
Ryan Orlando
Shane Sveller
Tom Leys