Field of View (FOV)
The next step towards a complete roguelike is FOV. This adds a tactical element, and lets the player wonder what’s on the other side of every door and every corner! The FOV works like a light source where the player stands, casting light in every direction but not getting past any walls. Regions in shadow are invisible. You could code it yourself by casting rays outward from the player, but it’s much easier than that; libtcod has a whole module dedicated to it! It includes different methods with varying levels of precision, speed and other interesting properties. There’s an excellent study here if you want to know more about them, including tables and images comparing the different algorithms.
We’ll define the chosen algorithm along with some other constants so
they can be changed later. For now we’ll just use the default
(Basic
) algorithm. There’s also an option to light walls or not;
this is a matter of preference. Another important constant is the
maximum radius for FOV calculations, how far the player can see in the
dungeon. (Whether this is due to the player’s sight range or the light
from the player’s torch depends on how you choose to explain this to
the player.)
const FOV_ALGO: FovAlgorithm = FovAlgorithm::Basic; // default FOV algorithm
const FOV_LIGHT_WALLS: bool = true; // light walls or not
const TORCH_RADIUS: i32 = 10;
And we need to add colours for the lit tiles:
const COLOR_DARK_WALL: Color = Color { r: 0, g: 0, b: 100 };
const COLOR_LIGHT_WALL: Color = Color {
r: 130,
g: 110,
b: 50,
};
const COLOR_DARK_GROUND: Color = Color {
r: 50,
g: 50,
b: 150,
};
const COLOR_LIGHT_GROUND: Color = Color {
r: 200,
g: 180,
b: 50,
};
The fov map object in tcod
is called Map
, which conflicts with our
own dungeon map type. So we’ll rename tcod’s to FovMap
on
import:
use tcod::map::{FovAlgorithm, Map as FovMap}; (1)
struct Tcod {
root: Root,
con: Offscreen,
fov: FovMap, (2)
}
1 | Bring the tcod::map::Map type in and alias it to FovMap |
2 | Add FovMap to the Tcod struct |
We need to create the FOV map and ad it to our tcod
variable in main
.
While we’re at it, we’ll initialise the Offscreen
console directly too:
let mut tcod = Tcod {
root,
con: Offscreen::new(MAP_WIDTH, MAP_HEIGHT), (1)
fov: FovMap::new(MAP_WIDTH, MAP_HEIGHT), (2)
};
1 | Initialise con inline |
2 | Initialise fov |
The libtcod FOV module needs to know which tiles block sight. So, we
create a map that libtcod can understand, and fill it with
the appropriate values from the tiles' own block_sight
and blocked
properties. Well, actually, only block_sight
will be used; the blocked
value is completely irrelevant for FOV! It will be useful only for the
pathfinding module, but it doesn’t hurt to provide that value anyway.
Also, libtcod asks for values that are the opposite of what we
defined, so we toggle them with the negation (!
) operator. This goes in the
main function, before entering the main game loop.
// populate the FOV map, according to the generated map
for y in 0..MAP_HEIGHT {
for x in 0..MAP_WIDTH {
tcod.fov.set(
x,
y,
!game.map[x as usize][y as usize].block_sight,
!game.map[x as usize][y as usize].blocked,
);
}
}
FOV needs to be recomputed — but only if the player moves or a tile changes. To that end, we’ll keep track of player’s position from the previous run of the game loop and compare it to the current position.
Add this before the main game loop:
// force FOV "recompute" first time through the game loop
let mut previous_player_position = (-1, -1);
(we’re using (-1, -1)
to make sure FOV gets computed on the first
time through the loop)
Then this right before handle_keys
(which is where the player’s
position could change):
previous_player_position = (player.x, player.y);
And lastly, replace the existing call to render_all
with these two lines:
// render the screen
let fov_recompute = previous_player_position != (objects[0].x, objects[0].y);
render_all(&mut tcod, &game, &objects, fov_recompute);
Now we need to change the rendering code to actually recompute FOV and display the result.
First, update the function definition to accept fov_map
and fov_recompute
:
fn render_all(tcod: &mut Tcod, game: &Game, objects: &[Object], fov_recompute: bool) {
Next, recompute the FOV map if the caller asked for it:
if fov_recompute {
// recompute FOV if needed (the player moved or something)
let player = &objects[0];
tcod.fov
.compute_fov(player.x, player.y, TORCH_RADIUS, FOV_LIGHT_WALLS, FOV_ALGO);
}
As you can see, we’re using all the constants we defined earlier.
We’ll extend the code that render the tiles so that if they are in the FOV
they will get their light
colours:
// go through all tiles, and set their background color
for y in 0..MAP_HEIGHT {
for x in 0..MAP_WIDTH {
let visible = tcod.fov.is_in_fov(x, y);
let wall = game.map[x as usize][y as usize].block_sight;
let color = match (visible, wall) {
// outside of field of view:
(false, true) => COLOR_DARK_WALL,
(false, false) => COLOR_DARK_GROUND,
// inside fov:
(true, true) => COLOR_LIGHT_WALL,
(true, false) => COLOR_LIGHT_GROUND,
};
tcod.con
.set_char_background(x, y, color, BackgroundFlag::Set);
}
}
We’ve replaced our if
with a match
. As we’ve added another
check (whether a tile is visible or not), we would have to use nested
ifs and end up with four separate calls to set_char_background
. But
since the only thing we’re changing is the colour, we’ll just pattern
match to get the right value and use it.
Finally, we’ll make sure we render only objects that are in the
player’s FOV. Wrap the object.draw
call in render_all
in a FOV
check:
// draw all objects in the list
for object in objects {
if tcod.fov.is_in_fov(object.x, object.y) {
object.draw(&mut tcod.con);
}
}
We’ve shuffled a lot of code around, but we haven’t changed much, conceptually. Just track whether the player moved, update FOV and render the map if they did, use lit vs. dark colours and only render visible objects.
And look how much better it looks now!
Exploration
The last detail after FOV is exploration, a.k.a Fog of War. You made it this far, so this will be a piece of cake! What, you may say, fog of war can’t possibly be the easiest thing to code in a roguelike! Well, it is. Wait and see.
First, all tiles will store whether they’re explored or not. They
start unexplored. Put this in the definition of the Tile
struct:
/// A tile of the map and its properties
#[derive(Clone, Copy, Debug)]
struct Tile {
blocked: bool,
explored: bool, (1)
block_sight: bool,
}
1 | Adde the explored field |
And you need to update the Tile::empty
and Tile::wall
bodies:
impl Tile {
pub fn empty() -> Self {
Tile {
blocked: false,
explored: false, (1)
block_sight: false,
}
}
pub fn wall() -> Self {
Tile {
blocked: true,
explored: false, (2)
block_sight: true,
}
}
}
1 | Added the explored field |
2 | Added the explored field |
Now, in the render_all
function, after the (visible, wall)
match
make sure the visible tiles are explored and only render those that
are:
let explored = &mut game.map[x as usize][y as usize].explored;
if visible {
// since it's visible, explore it
*explored = true;
}
if *explored {
// show explored tiles only (any visible tile is explored already)
tcod.con
.set_char_background(x, y, color, BackgroundFlag::Set);
}
(we take a mutable reference to the explored
field so we don’t have
to write the full map[x as usize][y as usize].explored
bit twice)
And finally, since we’re now actually modifying the map
, we’ll need
to pass a mutable reference to render_all
. Since map
is carried in the Game
struct,
we need to make the game
variable mutable:
let mut game = Game {
// generate map (at this point it's not drawn to the screen)
map: make_map(&mut objects[0]),
};
Then, we pass a mutable reference to game
to render_all
:
// render the screen
let fov_recompute = previous_player_position != (objects[0].x, objects[0].y);
render_all(&mut tcod, &mut game, &objects, fov_recompute); (1)
1 | game is now &mut |
And last, the render_all
function definition:
fn render_all(tcod: &mut Tcod, game: &mut Game, objects: &[Object], fov_recompute: bool) { (1)
1 | game is now &mut Game |
And that’s that! If you run the game now, you start in mostly black space except for your immediate surroundings and the map fills in as you explore.
One might argue that that render_all should not actually
modify anything and that the FOV/exploration code belongs somewhere
else. They wouldn’t necessarily be wrong. But let’s just roll with it
for now.
|
Here’s the complete code so far.
Continue to the next part.