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.
You may have noticed that this chapter is "57A" by filename. Some problems emerged with the spatial indexing system, after the AI changes in chapter 57. Rather than change an already oversized chapter with what is a decent topic in and of itself, I decided that it would be better to insert a section. In this chapter, we're going to revise the map_indexing_system and associated data. We have a few goals:
The stored locations of entities, and the "blocked" system should be easy to update mid-turn.
We want to eliminate entities sharing a space.
We want to fix the issue of not being able to enter a tile after an entity is slain.
Rather than scattering map's tile_content, the blocked list, the periodically updated system, and calls to these data structures everywhere, it would be a lot cleaner to move it behind a unified API. We could then access the API, and functionality changes automatically get pulled in as things improve. That way, we just have to remember to call the API - not remember how it works.
We'll start by making a module. Create a src\spatial directory, and put an empty mod.rs file in it. Then we'll "stub out" our spatial back-end, adding some content:
The SpatialMap struct contains the spatial information we are storing in Map. It's deliberately not public: we want to stop sharing the data directly, and use an API instead. Then we create a lazy_static: a mutex-protected global variable, and use that to store the spatial information. Storing it this way allows us to access it without burdening Specs' resources system - and makes it easier to offer access both from within systems and from the outside. Since we're mutex-protecting the spatial map, we also benefit from thread safety; that removes the resource from Specs' threading plan. This makes it easier for the program as a whole to use thread the dispatchers.
We already have map_indexing_system.rs, handling initial (per-frame, so it doesn't get far out of sync) population of the spatial map. Since we're changing how we're storing the data, we also need to change the system. The indexing system performs two functions on the map's spatial data: it sets tiles as blocked, and it adds indexed entities. We've already created the clear and populate_blocked_from_map functions it needs. Replace the body of the MapIndexingSystem's run function with:
#![allow(unused)]fnmain() {
use super::{Map, Position, BlocksTile, spatial};
...
fnrun(&mutself, data : Self::SystemData) {
let (mut map, position, blockers, entities) = data;
spatial::clear();
spatial::populate_blocked_from_map(&*map);
for (entity, position) in (&entities, &position).join() {
let idx = map.xy_idx(position.x, position.y);
// If they block, update the blocking listlet _p : Option<&BlocksTile> = blockers.get(entity);
ifletSome(_p) = _p {
spatial::set_blocked(idx);
}
// Push the entity to the appropriate index slot. It's a Copy// type, so we don't need to clone it (we want to avoid moving it out of the ECS!)
spatial::index_entity(entity, idx);
}
}
}
Time to break stuff! This will cause issues throughout the source-base. Remove blocked and tile_content from the map. The new Map definition is as follows:
Looking through the AI functions, we're often querying tile_content directly. Since we're trying for an API now, we can't do that! The most common use-case is iterating the vector representing a tile. We'd like to avoid the mess that results from returning a lock, and then ensuring that it is freed - this leaks too much implementation detail from an API. Instead, we'll provide a means of iterating tile content with a closure. Add the following to spatial/mod.rs:
#![allow(unused)]fnmain() {
pubfnfor_each_tile_content<F>(idx: usize, f: F)
where F : Fn(Entity)
{
let lock = SPATIAL_MAP.lock().unwrap();
for entity in lock.tile_content[idx].iter() {
f(*entity);
}
}
}
The f variable is a generic parameter, using where to specify that it must be a mutable function that accepts an Entity as a parameter. This gives us a similar interface to for_each on iterators: you can run a function on each entity in a tile, relying on closure capture to let you handle local state when calling it.
Open up src/ai/adjacent_ai_system.rs. The evaluate function was broken by our change. With the new API, fixing it is quite straightforward:
If you were wondering why I defined the API, and then changed it: it's so that you can see how the sausage is made. API building like this is always an iterative process, and it's good to see how things evolve.
Look at src/ai/approach_ai_system.rs. The code is pretty gnarly: we're manually changing blocked when the entity moves. Worse, we may not be doing it right! It simply unsets blocked; if for some reason the tile were still blocked, the result would be incorrect. That won't work; we need a clean way of moving entities around, and preserving the blocked status.
Adding a BlocksTile check to everything whenever we move things is going to be slow, and pollute our already-large Specs lookups with even more references. Instead, we'll change how we are storing entites. We'll also change how we are storing blocked. In spatial/mod.rs:
The blocked vector now contains a tuple of two bools. The first is "does the map block it?", the second is "is it blocked by an entity?". This requires that we change a few other functions. We're also going to delete the set_blocked function and make it automatic from the populate_blocked_from_map and index_entity functions. Automatic is good: there are fewer opportunities to shoot one's foot!
That requires that we tweak the map_indexing_system again. The great news is that it keeps getting shorter:
#![allow(unused)]fnmain() {
fnrun(&mutself, data : Self::SystemData) {
let (mut map, position, blockers, entities) = data;
spatial::clear();
spatial::populate_blocked_from_map(&*map);
for (entity, position) in (&entities, &position).join() {
let idx = map.xy_idx(position.x, position.y);
spatial::index_entity(entity, idx, blockers.get(entity).is_some());
}
}
}
So with that done, let's go back to approach_ai_system. Looking at the code, with the best of intentions we were trying to update blocked based on an entity having moved. We naievely cleared blocked from the source tile, and set it in the destination tile. We use that pattern a few times, so let's create an API function (in spatial/mod.rs) that actually works consistently:
This file is a little more complicated. The first broken section both queries and updates the blocked index. Change it to:
#![allow(unused)]fnmain() {
if x > 0 && x < map.width-1 && y > 0 && y < map.height-1 {
let dest_idx = map.xy_idx(x, y);
if !crate::spatial::is_blocked(dest_idx) {
let idx = map.xy_idx(pos.x, pos.y);
pos.x = x;
pos.y = y;
entity_moved.insert(entity, EntityMoved{}).expect("Unable to insert marker");
crate::spatial::move_entity(entity, idx, dest_idx);
viewshed.dirty = true;
}
}
}
The RandomWaypoint option is a very similar change:
#![allow(unused)]fnmain() {
if path.len()>1 {
if !crate::spatial::is_blocked(path[1] asusize) {
pos.x = path[1] asi32 % map.width;
pos.y = path[1] asi32 / map.width;
entity_moved.insert(entity, EntityMoved{}).expect("Unable to insert marker");
let new_idx = map.xy_idx(pos.x, pos.y);
crate::spatial::move_entity(entity, idx, new_idx);
viewshed.dirty = true;
path.remove(0); // Remove the first step in the path
}
// Otherwise we wait a turn to see if the path clears up
} else {
mode.mode = Movement::RandomWaypoint{ path : None };
}
}
The function try_move_player does a really big query of the spatial indexing system. It also sometimes returns mid-calculation, which our API doesn't currently support. We'll add a new function to our spatial/mod.rs file to enable this:
#![allow(unused)]fnmain() {
pubfnfor_each_tile_content_with_gamemode<F>(idx: usize, mut f: F) -> RunState
where F : FnMut(Entity)->Option<RunState>
{
let lock = SPATIAL_MAP.lock().unwrap();
for entity in lock.tile_content[idx].iter() {
ifletSome(rs) = f(entity.0) {
return rs;
}
}
RunState::AwaitingInput
}
}
This function runs like the other one, but accepts an optional game mode from the closure. If the game mode is Some(x), then it returns x. If it hasn't received any modes by the end, it returns AwaitingInput.
Replacing it with the new API is mostly a matter of using the new functions, and performing the index check inside the closure. Here's the new function:
#![allow(unused)]fnmain() {
pubfntry_move_player(delta_x: i32, delta_y: i32, ecs: &mut World) -> RunState {
letmut positions = ecs.write_storage::<Position>();
let players = ecs.read_storage::<Player>();
letmut viewsheds = ecs.write_storage::<Viewshed>();
let entities = ecs.entities();
let combat_stats = ecs.read_storage::<Attributes>();
let map = ecs.fetch::<Map>();
letmut wants_to_melee = ecs.write_storage::<WantsToMelee>();
letmut entity_moved = ecs.write_storage::<EntityMoved>();
letmut doors = ecs.write_storage::<Door>();
letmut blocks_visibility = ecs.write_storage::<BlocksVisibility>();
letmut blocks_movement = ecs.write_storage::<BlocksTile>();
letmut renderables = ecs.write_storage::<Renderable>();
let factions = ecs.read_storage::<Faction>();
letmut result = RunState::AwaitingInput;
letmut swap_entities : Vec<(Entity, i32, i32)> = Vec::new();
for (entity, _player, pos, viewshed) in (&entities, &players, &mut positions, &mut viewsheds).join() {
if pos.x + delta_x < 1 || pos.x + delta_x > map.width-1 || pos.y + delta_y < 1 || pos.y + delta_y > map.height-1 { return RunState::AwaitingInput; }
let destination_idx = map.xy_idx(pos.x + delta_x, pos.y + delta_y);
result = crate::spatial::for_each_tile_content_with_gamemode(destination_idx, |potential_target| {
letmut hostile = true;
if combat_stats.get(potential_target).is_some() {
ifletSome(faction) = factions.get(potential_target) {
let reaction = crate::raws::faction_reaction(
&faction.name,
"Player",
&crate::raws::RAWS.lock().unwrap()
);
if reaction != Reaction::Attack { hostile = false; }
}
}
if !hostile {
// Note that we want to move the bystander
swap_entities.push((potential_target, pos.x, pos.y));
// Move the player
pos.x = min(map.width-1 , max(0, pos.x + delta_x));
pos.y = min(map.height-1, max(0, pos.y + delta_y));
entity_moved.insert(entity, EntityMoved{}).expect("Unable to insert marker");
viewshed.dirty = true;
letmut ppos = ecs.write_resource::<Point>();
ppos.x = pos.x;
ppos.y = pos.y;
returnSome(RunState::Ticking);
} else {
let target = combat_stats.get(potential_target);
ifletSome(_target) = target {
wants_to_melee.insert(entity, WantsToMelee{ target: potential_target }).expect("Add target failed");
returnSome(RunState::Ticking);
}
}
let door = doors.get_mut(potential_target);
ifletSome(door) = door {
door.open = true;
blocks_visibility.remove(potential_target);
blocks_movement.remove(potential_target);
let glyph = renderables.get_mut(potential_target).unwrap();
glyph.glyph = rltk::to_cp437('/');
viewshed.dirty = true;
returnSome(RunState::Ticking);
}
None
});
if !crate::spatial::is_blocked(destination_idx) {
let old_idx = map.xy_idx(pos.x, pos.y);
pos.x = min(map.width-1 , max(0, pos.x + delta_x));
pos.y = min(map.height-1, max(0, pos.y + delta_y));
let new_idx = map.xy_idx(pos.x, pos.y);
entity_moved.insert(entity, EntityMoved{}).expect("Unable to insert marker");
crate::spatial::move_entity(entity, old_idx, new_idx);
viewshed.dirty = true;
letmut ppos = ecs.write_resource::<Point>();
ppos.x = pos.x;
ppos.y = pos.y;
result = RunState::Ticking;
match map.tiles[destination_idx] {
TileType::DownStairs => result = RunState::NextLevel,
TileType::UpStairs => result = RunState::PreviousLevel,
_ => {}
}
}
}
for m in swap_entities.iter() {
let their_pos = positions.get_mut(m.0);
ifletSome(their_pos) = their_pos {
let old_idx = map.xy_idx(their_pos.x, their_pos.y);
their_pos.x = m.1;
their_pos.y = m.2;
let new_idx = map.xy_idx(their_pos.x, their_pos.y);
crate::spatial::move_entity(m.0, old_idx, new_idx);
result = RunState::Ticking;
}
}
result
}
}
Notice the TODO: we're going to want to look at that before we are done. We're moving entities around - and not updating the spatial map.
The skip_turn also needs to replace direct iteration of tile_content with the new closure-based setup:
#![allow(unused)]fnmain() {
crate::spatial::for_each_tile_content(idx, |entity_id| {
let faction = factions.get(entity_id);
match faction {
None => {}
Some(faction) => {
let reaction = crate::raws::faction_reaction(
&faction.name,
"Player",
&crate::raws::RAWS.lock().unwrap()
);
if reaction == Reaction::Attack {
can_heal = false;
}
}
}
});
}
trigger_system.rs also needs some love. This is just another direct for loop replacement with the new closure:
#![allow(unused)]fnmain() {
crate::spatial::for_each_tile_content(idx, |entity_id| {
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 itlet name = names.get(entity_id);
ifletSome(name) = name {
log.entries.push(format!("{} triggers!", &name.name));
}
hidden.remove(entity_id); // The trap is no longer hidden// If the trap is damage inflicting, do itlet damage = inflicts_damage.get(entity_id);
ifletSome(damage) = damage {
particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::ORANGE), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('‼'), 200.0);
SufferDamage::new_damage(&mut inflict_damage, entity, damage.damage, false);
}
// If it is single activation, it needs to be removedlet sa = single_activation.get(entity_id);
ifletSome(_sa) = sa {
remove_entities.push(entity_id);
}
}
}
}
});
}
If you cargo build, it now compiles! That's progress. Now cargo run the project, and see how it goes. The game runs at a decent speed, and is playable. There are still a few issues - we'll resolve these in turn.
We'll start with the "dead still bock tiles" problem. The problem occurs because entities don't go away until delete_the_dead is called, and the whole map reindexes. That may not occur in time to help with moving into the target tile. Add a new function to our spatial API (in spatial/mod.rs):
That sounds good - but running it shows that we still have the problem. A bit of heavy debugging showed that map_indexing_system is running inbetween the events, and restoring the incorrect data. We don't want the dead to show up on our indexed map, so we edit the indexing system to check. The fixed indexing system looks like this: we've added a check for dead people.
#![allow(unused)]fnmain() {
use specs::prelude::*;
use super::{Map, Position, BlocksTile, Pools, spatial};
pubstructMapIndexingSystem {}
impl<'a> System<'a> for MapIndexingSystem {
typeSystemData = ( ReadExpect<'a, Map>,
ReadStorage<'a, Position>,
ReadStorage<'a, BlocksTile>,
ReadStorage<'a, Pools>,
Entities<'a>,);
fnrun(&mutself, data : Self::SystemData) {
let (map, position, blockers, pools, entities) = data;
spatial::clear();
spatial::populate_blocked_from_map(&*map);
for (entity, position) in (&entities, &position).join() {
letmut alive = true;
ifletSome(pools) = pools.get(entity) {
if pools.hit_points.current < 1 {
alive = false;
}
}
if alive {
let idx = map.xy_idx(position.x, position.y);
spatial::index_entity(entity, idx, blockers.get(entity).is_some());
}
}
}
}
}
You can now move into the space occupied by the recently deceased.
Remember that we marked a TODO in the player handler, for when we want to swap entities positions? Let's get that figured out. Here's a version that updates the destinations:
#![allow(unused)]fnmain() {
for m in swap_entities.iter() {
let their_pos = positions.get_mut(m.0);
ifletSome(their_pos) = their_pos {
let old_idx = map.xy_idx(their_pos.x, their_pos.y);
their_pos.x = m.1;
their_pos.y = m.2;
let new_idx = map.xy_idx(their_pos.x, their_pos.y);
crate::spatial::move_entity(m.0, old_idx, new_idx);
result = RunState::Ticking;
}
}
}
It still isn't absolutely perfect, but it's a lot better. I played for a while, and on release mode it is zoomy. Issues with not being able to enter tiles are gone, hit detection is working. Equally importantly, we've cleaned up some hacky code.
Note: this chapter is in alpha. I'm still applying these fixes to subsequent chapters, and will update this when it is done.
...
The source code for this chapter may be found here