About this tutorial
This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!
If you enjoy this and would like me to keep writing, please consider supporting my Patreon.
Most roguelikes, like their D&D precursors, feature traps in the dungeon. Walk down an innocent looking hallway, and oops - an arrow flies out and hits you. This chapter will implement some simple traps, and then examine some of the game implications they bring.
Most traps follow the pattern of: you might see the trap (or you might not!), you enter the tile anyway, the trap goes off 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.
A lot of roguelikes use ^
for a trap, so we'll do the same. We have all the components required to render a basic object, so we'll make a new spawning function (in spawners.rs
). It's pretty much the minimum to put a glyph on the map:
fn bear_trap(ecs: &mut World, x: i32, y: i32) {
ecs.create_entity()
.with(Position{ x, y })
.with(Renderable{
glyph: rltk::to_cp437('^'),
fg: RGB::named(rltk::RED),
bg: RGB::named(rltk::BLACK),
render_order: 2
})
.with(Name{ name : "Bear Trap".to_string() })
.marked::<SimpleMarker<SerializeMe>>()
.build();
}
We'll also add it into the list of things that can spawn:
fn room_table(map_depth: i32) -> RandomTable {
RandomTable::new()
.add("Goblin", 10)
.add("Orc", 1 + map_depth)
.add("Health Potion", 7)
.add("Fireball Scroll", 2 + map_depth)
.add("Confusion Scroll", 2 + map_depth)
.add("Magic Missile Scroll", 4)
.add("Dagger", 3)
.add("Shield", 3)
.add("Longsword", map_depth - 1)
.add("Tower Shield", map_depth - 1)
.add("Rations", 10)
.add("Magic Mapping Scroll", 2)
.add("Bear Trap", 2)
}
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!
It is pretty easy if you can always know that a trap awaits you! So we want to make traps hidden by default, and come up with a way to sometimes locate traps when you are near them. Like most things in an ECS driven world, analyzing the text gives a great clue as to what components you need. In this case, we need to go into components.rs
and create a new component - Hidden
:
#[derive(Component, Debug, Serialize, Deserialize, Clone)]
pub struct Hidden {}
As usual, we need to register it in main.rs
and in saveload_system.rs
. We'll also give the property to our new bear trap:
fn bear_trap(ecs: &mut World, x: i32, y: i32) {
ecs.create_entity()
.with(Position{ x, y })
.with(Renderable{
glyph: rltk::to_cp437('^'),
fg: RGB::named(rltk::RED),
bg: RGB::named(rltk::BLACK),
render_order: 2
})
.with(Name{ name : "Bear Trap".to_string() })
.with(Hidden{})
.marked::<SimpleMarker<SerializeMe>>()
.build();
}
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
):
let mut data = (&positions, &renderables, !&hidden).join().collect::<Vec<_>>();
data.sort_by(|&a, &b| b.1.render_order.cmp(&a.1.render_order) );
for (pos, render, _hidden) in data.iter() {
let idx = map.xy_idx(pos.x, pos.y);
if map.visible_tiles[idx] { ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph) }
}
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:
fn draw_tooltips(ecs: &World, ctx : &mut Rltk) {
let map = ecs.fetch::<Map>();
let names = ecs.read_storage::<Name>();
let positions = ecs.read_storage::<Position>();
let hidden = ecs.read_storage::<Hidden>();
let mouse_pos = ctx.mouse_pos();
if mouse_pos.0 >= map.width || mouse_pos.1 >= map.height { return; }
let mut tooltip : Vec<String> = Vec::new();
for (name, position, _hidden) in (&names, &positions, !&hidden).join() {
if position.x == mouse_pos.0 && position.y == mouse_pos.1 {
tooltip.push(name.name.to_string());
}
}
...
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!
A trap should trigger when an entity walks onto them. So in components.rs
, we'll create an EntryTrigger
(as usual, we'll also register it in main.rs
and saveload_system.rs
):
#[derive(Component, Debug, Serialize, Deserialize, Clone)]
pub struct EntryTrigger {}
We'll give bear traps a trigger (in spawner.rs
):
fn bear_trap(ecs: &mut World, x: i32, y: i32) {
ecs.create_entity()
.with(Position{ x, y })
.with(Renderable{
glyph: rltk::to_cp437('^'),
fg: RGB::named(rltk::RED),
bg: RGB::named(rltk::BLACK),
render_order: 2
})
.with(Name{ name : "Bear Trap".to_string() })
.with(Hidden{})
.with(EntryTrigger{})
.marked::<SimpleMarker<SerializeMe>>()
.build();
}
We also need to have traps fire 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
):
#[derive(Component, Debug, Serialize, Deserialize, Clone)]
pub struct EntityMoved {}
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:
let mut entity_moved = ecs.write_storage::<EntityMoved>();
Then when we've determined that the player did, in fact, move - we'll insert the EntityMoved
component:
entity_moved.insert(entity, EntityMoved{}).expect("Unable to insert marker");
The other location that features movement is the Monster AI. So in monster_ai_system.rs
, we do something similar. We add a WriteResource
for the EntityMoved
component, and insert one after the monster moves. The source code for the AI is getting a bit long, so I recommend you look at the source file directly for this one (here).
Lastly, we need a system to make triggers actually do something. We'll make a new file, trigger_system.rs
:
extern crate specs;
use specs::prelude::*;
use super::{EntityMoved, Position, EntryTrigger, Hidden, Map, Name, gamelog::GameLog};
pub struct TriggerSystem {}
impl<'a> System<'a> for TriggerSystem {
#[allow(clippy::type_complexity)]
type SystemData = ( ReadExpect<'a, Map>,
WriteStorage<'a, EntityMoved>,
ReadStorage<'a, Position>,
ReadStorage<'a, EntryTrigger>,
WriteStorage<'a, Hidden>,
ReadStorage<'a, Name>,
Entities<'a>,
WriteExpect<'a, GameLog>);
fn run(&mut self, data : Self::SystemData) {
let (map, mut entity_moved, position, entry_trigger, mut hidden, names, entities, mut log) = data;
// Iterate the entities that moved and their final position
for (entity, mut _entity_moved, pos) in (&entities, &mut entity_moved, &position).join() {
let idx = map.xy_idx(pos.x, pos.y);
for entity_id in map.tile_content[idx].iter() {
if entity != *entity_id { // Do not bother to check yourself for being a trap!
let maybe_trigger = entry_trigger.get(*entity_id);
match maybe_trigger {
None => {},
Some(_trigger) => {
// We triggered it
let name = names.get(*entity_id);
if let Some(name) = name {
log.entries.insert(0, format!("{} triggers!", &name.name));
}
hidden.remove(*entity_id); // The trap is no longer hidden
}
}
}
}
}
// Remove all entity movement markers
entity_moved.clear();
}
}
This is relatively straightforward if you've been through the previous chapters:
Position
and an EntityMoved
component.tile_content
index to see what's in the new tile.hidden
component from the trap, since we now know that it is there.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);
...
So that gets us a long way: traps can be sprinkled around the level, and trigger when you enter their target tile. It would help if the trap did something! We actually have a decent number of component types to describe the effect. In spawner.rs
, we'll extend the bear trap to include some damage:
fn bear_trap(ecs: &mut World, x: i32, y: i32) {
ecs.create_entity()
.with(Position{ x, y })
.with(Renderable{
glyph: rltk::to_cp437('^'),
fg: RGB::named(rltk::RED),
bg: RGB::named(rltk::BLACK),
render_order: 2
})
.with(Name{ name : "Bear Trap".to_string() })
.with(Hidden{})
.with(EntryTrigger{})
.with(InflictsDamage{ damage: 6 })
.marked::<SimpleMarker<SerializeMe>>()
.build();
}
We'll also extend the trigger_system
to apply the damage:
// If the trap is damage inflicting, do it
let damage = inflicts_damage.get(*entity_id);
if let Some(damage) = damage {
particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::ORANGE), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('‼'), 200.0);
inflict_damage.insert(entity, SufferDamage{ amount: damage.damage }).expect("Unable to do damage");
}
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 effect for attacking.
Some traps, like a bear trap (think a spring with spikes) really only fire once. That seems like a useful property to model for our trigger system, so we'll add a new component (to components.rs
, main.rs
and saveload_system.rs
):
#[derive(Component, Debug, Serialize, Deserialize, Clone)]
pub struct SingleActivation {}
We'll also add it to the Bear Trap function in spawner.rs
:
.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.
extern crate specs;
use specs::prelude::*;
use super::{EntityMoved, Position, EntryTrigger, Hidden, Map, Name, gamelog::GameLog,
InflictsDamage, particle_system::ParticleBuilder, SufferDamage, SingleActivation};
pub struct TriggerSystem {}
impl<'a> System<'a> for TriggerSystem {
#[allow(clippy::type_complexity)]
type SystemData = ( ReadExpect<'a, Map>,
WriteStorage<'a, EntityMoved>,
ReadStorage<'a, Position>,
ReadStorage<'a, EntryTrigger>,
WriteStorage<'a, Hidden>,
ReadStorage<'a, Name>,
Entities<'a>,
WriteExpect<'a, GameLog>,
ReadStorage<'a, InflictsDamage>,
WriteExpect<'a, ParticleBuilder>,
WriteStorage<'a, SufferDamage>,
ReadStorage<'a, SingleActivation>);
fn run(&mut self, data : Self::SystemData) {
let (map, mut entity_moved, position, entry_trigger, mut hidden,
names, entities, mut log, inflicts_damage, mut particle_builder,
mut inflict_damage, single_activation) = data;
// Iterate the entities that moved and their final position
let mut remove_entities : Vec<Entity> = Vec::new();
for (entity, mut _entity_moved, pos) in (&entities, &mut entity_moved, &position).join() {
let idx = map.xy_idx(pos.x, pos.y);
for entity_id in map.tile_content[idx].iter() {
if entity != *entity_id { // Do not bother to check yourself for being a trap!
let maybe_trigger = entry_trigger.get(*entity_id);
match maybe_trigger {
None => {},
Some(_trigger) => {
// We triggered it
let name = names.get(*entity_id);
if let Some(name) = name {
log.entries.insert(0, format!("{} triggers!", &name.name));
}
hidden.remove(*entity_id); // The trap is no longer hidden
// If the trap is damage inflicting, do it
let damage = inflicts_damage.get(*entity_id);
if let Some(damage) = damage {
particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::ORANGE), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('‼'), 200.0);
inflict_damage.insert(entity, SufferDamage{ amount: damage.damage }).expect("Unable to do damage");
}
// If it is single activation, it needs to be removed
let sa = single_activation.get(*entity_id);
if let Some(_sa) = sa {
remove_entities.push(*entity_id);
}
}
}
}
}
}
// Remove any single activation traps
for trap in remove_entities.iter() {
entities.delete(*trap).expect("Unable to delete trap");
}
// Remove all entity movement markers
entity_moved.clear();
}
}
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.
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
:
extern crate specs;
use specs::prelude::*;
use super::{Viewshed, Position, Map, Player, Hidden, gamelog::GameLog};
extern crate rltk;
use rltk::{field_of_view, Point};
pub struct VisibilitySystem {}
impl<'a> System<'a> for VisibilitySystem {
#[allow(clippy::type_complexity)]
type SystemData = ( WriteExpect<'a, Map>,
Entities<'a>,
WriteStorage<'a, Viewshed>,
ReadStorage<'a, Position>,
ReadStorage<'a, Player>,
WriteStorage<'a, Hidden>,
WriteExpect<'a, rltk::RandomNumberGenerator>,
WriteExpect<'a, GameLog>,
ReadStorage<'a, Name>,);
fn run(&mut self, data : Self::SystemData) {
let (mut map, entities, mut viewshed, pos, player,
mut hidden, mut rng, mut log, names) = data;
for (ent,viewshed,pos) in (&entities, &mut viewshed, &pos).join() {
if viewshed.dirty {
viewshed.dirty = false;
viewshed.visible_tiles = field_of_view(Point::new(pos.x, pos.y), viewshed.range, &*map);
// If this is the player, reveal what they can see
let _p : Option<&Player> = player.get(ent);
if let Some(_p) = _p {
for t in map.visible_tiles.iter_mut() { *t = false };
for vis in viewshed.visible_tiles.iter() {
let idx = map.xy_idx(vis.x, vis.y);
map.revealed_tiles[idx] = true;
map.visible_tiles[idx] = true;
// Chance to reveal hidden things
for e in map.tile_content[idx].iter() {
let maybe_hidden = hidden.get(*e);
if let Some(_maybe_hidden) = maybe_hidden {
if rng.roll_dice(1,24)==1 {
let name = names.get(*e);
if let Some(name) = name {
log.entries.insert(0, format!("You spotted a {}.", &name.name));
}
hidden.remove(*e);
}
}
}
}
}
}
}
}
}
So why a 1 in 24 chance to spot traps? I played around until it felt about right. 1 in 6 (my first 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.
The source code for this chapter may be found here
Copyright (C) 2019, Herbert Wolverson.