Off-screen consoles
Before we continue, let’s talk about consoles. In libtcod, console is where we draw stuff. We’ve only used one so far — the root console. It is connected to the game window and anything you want to show must eventually make its way there.
We can, however, create so-called offscreen consoles and draw things on them. Doing so will let us add transparency effects or only render a portion of the console. It will also let us stack GUI windows on top of the main game, render the info panel and the map separately, etc.
From now on, we will draw to offscreen consoles and then compose them into the root. It will occupy the whole screen for now.
Put this right after we initialise the root console:
let con = Offscreen::new(SCREEN_WIDTH, SCREEN_HEIGHT);
We will want to pass con
to our Tcod
struct alongside root
:
let mut tcod = Tcod { root, con };
And of course we will need to add the con
field to the Tcod
struct definition:
struct Tcod {
root: Root,
con: Offscreen,
}
Now call the set_default_foreground
, clear
and put_char
methods
on con
instead of root
:
tcod.con.set_default_foreground(colors::WHITE);
tcod.con.clear();
tcod.con.put_char(player_x, player_y, '@', BackgroundFlag::None);
And finally, blit the contents of the new console to the root console
to display them. The blit
function takes a lot of parameters, but
the meaning is straightforward. We take the console we want to blit
from (i.e. con
), the coordinates where to start and the width and
height of the area we want to blit (we’ll blit it all). Then the
destination (root
), where to start blitting (we’ll use the
top-left corner) and finally a foreground and background transparency
(0.0
is fully transparent, 1.0
completely opaque).
// blit the contents of "con" to the root console and present it
blit(
&tcod.con,
(0, 0),
(SCREEN_WIDTH, SCREEN_HEIGHT),
&mut tcod.root,
(0, 0),
1.0,
1.0,
);
Generalising
Now that we have the @
walking around, it would be a good idea to
step back and think a bit about the design. Having variables for the
player’s coordinates is easy, but it can quickly get out of control
when you’re defining things such as HP, bonuses, and inventory. We’re
going to take the opportunity to generalize a bit.
Now, there can be such a thing as over-generalization, but we’ll try
not to fall in that trap. What we’re going to do is define the player
as a game Object
. It will hold all position and display information
(character and color). The neat thing is that the player will just be
one instance of the Object
struct — it’s general enough that you
can re-use it to define items on the floor, monsters, doors, stairs;
anything representable by a character on the screen. Here’s the class,
with the initialization, and two common methods move_by
and draw
.
The code for drawing and erasing is the same as the one we used for
the player earlier.
/// This is a generic object: the player, a monster, an item, the stairs...
/// It's always represented by a character on screen.
struct Object {
x: i32,
y: i32,
char: char,
color: Color,
}
impl Object {
pub fn new(x: i32, y: i32, char: char, color: Color) -> Self {
Object { x, y, char, color }
}
/// move by the given amount
pub fn move_by(&mut self, dx: i32, dy: i32) {
self.x += dx;
self.y += dy;
}
/// set the color and then draw the character that represents this object at its position
pub fn draw(&self, con: &mut dyn Console) {
con.set_default_foreground(self.color);
con.put_char(self.x, self.y, self.char, BackgroundFlag::None);
}
}
The dyn
keyword in &mut dyn Console
highlights that Console
is a trait
and not a concrete type (such as a struct
or enum
).
Earlier versions of Rust allowed to say &Type
regardless of whether Type
was trait
or not.
And indeed at least in Rust 1.37 2018 this is still allowed, but it will emit a warning.
This has real-life implications as Rust’s pointers to traits are twice the size of pointers to structs.
This way anyone reading the code (us, our collaborators, people taking over) can always tell whether a pointer signature (reference, box, etc.) is a trait object or a normal pointer.
Now, before the main loop, we will create an actual player Object
.
We will also add it to an array that will hold all objects in the
game. While we’re at it, we’ll add a yellow @
that represents a
non-player character to test things out:
// create object representing the player
let player = Object::new(SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2, '@', WHITE);
// create an NPC
let npc = Object::new(SCREEN_WIDTH / 2 - 5, SCREEN_HEIGHT / 2, '@', YELLOW);
// the list of objects with those two
let mut objects = [player, npc];
Now we’ll need to do a few changes to make it work. First in
handle_keys
we’ll use player’s move_by
method to change the
coordinates. This means we’ll need to pass in (a mutable reference to) the
player object.
fn handle_keys(tcod: &mut Tcod, player: &mut Object) -> bool {
// ...
// movement keys
Key { code: Up, .. } => player.move_by(0, -1),
Key { code: Down, .. } => player.move_by(0, 1),
Key { code: Left, .. } => player.move_by(-1, 0),
Key { code: Right, .. } => player.move_by(1, 0),
// ...
}
and update the way we call the function:
// handle keys and exit game if needed
let player = &mut objects[0];
let exit = handle_keys(&mut tcod, player);
Next, the main loop will now draw all objects like so:
for object in &objects {
object.draw(&mut tcod.con);
}
And finally, since we’re now handling colour and rendering in the Object::draw
method,
we can now remove the set_default_foreground
and put_char
calls from the main loop and
instead just clear the offscreen console:
// clear the screen of the previous frame
tcod.con.clear();
And that’s it! We have a fully generic object system. Later we can modify this to have all the info items, monsters and anything else will require.
Here’s the code so far.
The Map
Now let’s build a map which will hold our dungeon! It will be a two-dimensional array of tiles. We’ll define its size on top of the source file to be slightly smaller than the window size. This will leave some space for a panel with stats that we’ll add later.
// size of the map
const MAP_WIDTH: i32 = 80;
const MAP_HEIGHT: i32 = 45;
Next we’ll define colours for the tiles. We’ll have two tiles for now: wall and ground. Let’s define their dark variants. When we add field of view, we’ll have to add a set for when they’re lit.
const COLOR_DARK_WALL: Color = Color { r: 0, g: 0, b: 100 };
const COLOR_DARK_GROUND: Color = Color {
r: 50,
g: 50,
b: 150,
};
Since the map is going to be built from tiles, we need to define them! We’ll start with two values: whether a tile is passable and whether it blocks sight.
It’s good to keep the values separate from the beginning as it will
let us have see-through but impassable tiles such as chasms or
passable tiles that block sight for secret passages. We’ll create a
Tile
struct:
/// A tile of the map and its properties
#[derive(Clone, Copy, Debug)]
struct Tile {
blocked: bool,
block_sight: bool,
}
impl Tile {
pub fn empty() -> Self {
Tile {
blocked: false,
block_sight: false,
}
}
pub fn wall() -> Self {
Tile {
blocked: true,
block_sight: true,
}
}
}
The #[derive(…)]
bit automatically implements certain behaviours
(Rust calls them traits, other languages use interfaces) you list
there. Debug
is to let us print the Tile’s contents and Clone
and
Copy
will let us copy the values on assignment or function call
instead of moving them. So they’ll behave like e.g. integers in this
respect.
We don’t want the Copy
behaviour for Object
(we could accidentally
modify a copy instead of the original and get our changes lost for
example), but Debug
is useful, so let’s add the Debug
derive to
our Object
as well:
#[derive(Debug)]
We’ve also added helper methods to build the two types of Tiles
we’re going to be using the most.
And now the map! It’s a two-dimensional array (Vec
) of tiles. The
full type is Vec<Vec<Tile>>
(a vec composed of vecs of tiles). Since
we’re going to be passing it around a lot, let’s define a shortcut:
type Map = Vec<Vec<Tile>>;
struct Game {
map: Map,
}
This let’s use write Map
wherever we’d have to write
Vec<Vec<Tile>>
and it’s also easier to understand.
And we’ve also created a new Game
struct. The motivation here is
identical to the Tcod
struct: there are going to be things we will
almost always want to pass together and this will save us some
refactoring later.
It will also come in super handy when we get to saving and loading.
Now we’ll build it using nested vec!
macros:
fn make_map() -> Map {
// fill map with "unblocked" tiles
let mut map = vec![vec![Tile::empty(); MAP_HEIGHT as usize]; MAP_WIDTH as usize];
map
}
The vec!
macro is a shortcut that creates a Vec
and fills it with
values. For example, vec!['a'; 42]
would create a Vec containing the
letter 'a' 42 times. We do the same trick above to build a column of
tiles and then build the map of those columns.
We can access any tile with map[x][y]
. Let’s add two pillars
(blocked tiles) to demonstrate that and provide a simple test:
// place two pillars to test the map
map[30][22] = Tile::wall();
map[50][22] = Tile::wall();
(you can also access the tile’s properties directly like so:
map[30][22].blocked = true
)
Next we need to draw the map on our window. Since we need to draw both the objects and the map, let’s create a new function that renders everything and call it from the main loop.
fn render_all(tcod: &mut Tcod, game: &Game, objects: &[Object]) {
// draw all objects in the list
for object in objects {
object.draw(&mut tcod.con);
}
}
Still in the same function, we can go through all the tiles and draw them to the screen:
// go through all tiles, and set their background color
for y in 0..MAP_HEIGHT {
for x in 0..MAP_WIDTH {
let wall = game.map[x as usize][y as usize].block_sight;
if wall {
tcod.con
.set_char_background(x, y, COLOR_DARK_WALL, BackgroundFlag::Set);
} else {
tcod.con
.set_char_background(x, y, COLOR_DARK_GROUND, BackgroundFlag::Set);
}
}
}
And let’s move the blit
call to the end of render_all
:
// blit the contents of "con" to the root console
blit(
&tcod.con,
(0, 0),
(MAP_WIDTH, MAP_HEIGHT),
&mut tcod.root,
(0, 0),
1.0,
1.0,
);
We’ve replaced the SCREEN_*
dimensions with the MAP
ones. From now
on, the con
offscreen console object will represents the map only.
This gives some space at the bottom for the message log, status bar, etc.
And we need to update its dimensions (in the main
fn) as well:
let con = Offscreen::new(MAP_WIDTH, MAP_HEIGHT);
Now that we’ve got the map and rendering updated, let’s actually
create it. In main
before the game loop:
let game = Game {
// generate map (at this point it's not drawn to the screen)
map: make_map(),
};
And don’t forget to call render_all
from the main loop too (right
before tcod.flush
):
// render the screen
render_all(&mut tcod, &game, &objects);
You should be able to see two pillars and walk around the map now!
But wait, there’s something wrong. The pillars show up, but the player
can walk over them. That’s easy to fix though, add this check to the
beginning of the Object’s move_by
method:
/// move by the given amount, if the destination is not blocked
pub fn move_by(&mut self, dx: i32, dy: i32, game: &Game) { (1)
if !game.map[(self.x + dx) as usize][(self.y + dy) as usize].blocked { (2)
self.x += dx; (3)
self.y += dy;
}
}
1 | We need to pass Map in to check if a tile is blocking |
2 | Only move if the destination is not blocking |
3 | The movement code is the same |
We’ll also need to pass a reference to the map to handle_keys
because it calls move_by
. This may look annoying now but as the code
grows, it will be good to know which functions can see (and change!)
what.
fn handle_keys(tcod: &mut Tcod, game: &Game, player: &mut Object) -> bool { (1)
//...
match key {
// ...
// movement keys
Key { code: Up, .. } => player.move_by(0, -1, game), (2)
Key { code: Down, .. } => player.move_by(0, 1, game),
Key { code: Left, .. } => player.move_by(-1, 0, game),
Key { code: Right, .. } => player.move_by(1, 0, game),
// ...
}
// ...
}
1 | Added Game to handle_keys |
2 | Passing game to move_by |
And finally, we need to pass the map to handle_keys
from the main loop:
let exit = handle_keys(&mut tcod, &game, player);
Here’s the complete code so far.
There’s a ton of different ways to create the map. One common
alternative is one continuous Vec with MAP_HEIGHT * MAP_WIDTH items.
To access a tile on (x, y) , you would do map[y * MAP_WIDTH + x] .
The advantage is that you only do one array lookup instead of two and
iterating over every object in the map is faster because they’re all
in the same region of memory.
|
Or you could treat walls and everything else in the map as just
another Object and store them there. This would make the game
structure simpler (everything is an Object ) and more flexible
(just add HP to make a wall destructible, or damage to one that’s
supposed to be covered with spikes).
|
Continue to the next part.