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 finish to keep playing), and not too intrusive. Particles are a good fit for this, so we'll implement a simple ASCII/CP437 particle system.
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'll make a new file, particle_system.rs. It won't be a regular system, because we need access to the RLTK Context object - but it will have to provide services to other systems.
The first thing to support is making particles vanish after their lifetime. So we start with the following in particle_system.rs:
#![allow(unused)]fnmain() {
use specs::prelude::*;
use super::{ Rltk, ParticleLifetime};
pubfncull_dead_particles(ecs : &mut World, ctx : &Rltk) {
letmut dead_particles : Vec<Entity> = Vec::new();
{
// Age out particlesletmut particles = ecs.write_storage::<ParticleLifetime>();
let entities = ecs.entities();
for (entity, mut particle) in (&entities, &mut particles).join() {
particle.lifetime_ms -= ctx.frame_time_ms;
if particle.lifetime_ms < 0.0 {
dead_particles.push(entity);
}
}
}
for dead in dead_particles.iter() {
ecs.delete_entity(*dead).expect("Particle will not die");
}
}
}
Then we modify the render loop in main.rs to call it:
Let's extend particle_system.rs to offer a builder system: you obtain a ParticleBuilder and add requests to it, and then create your particles as a batch together. We'll offer the particle system as a resource - so it's available anywhere. This 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:
Now, we'll return to particle_system.rs and build an actual system to spawn particles. The system looks like this:
#![allow(unused)]fnmain() {
pubstructParticleSpawnSystem {}
impl<'a> System<'a> for ParticleSpawnSystem {
#[allow(clippy::type_complexity)]typeSystemData = (
Entities<'a>,
WriteStorage<'a, Position>,
WriteStorage<'a, Renderable>,
WriteStorage<'a, ParticleLifetime>,
WriteExpect<'a, ParticleBuilder>
);
fnrun(&mutself, data : Self::SystemData) {
let (entities, mut positions, mut renderables, mut particles, mut particle_builder) = data;
for new_particle in particle_builder.requests.iter() {
let p = entities.create();
positions.insert(p, Position{ x: new_particle.x, y: new_particle.y }).expect("Unable to inser position");
renderables.insert(p, Renderable{ fg: new_particle.fg, bg: new_particle.bg, glyph: new_particle.glyph, render_order: 0 }).expect("Unable to insert renderable");
particles.insert(p, ParticleLifetime{ lifetime_ms: new_particle.lifetime }).expect("Unable to insert lifetime");
}
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.
Lets start by spawning a particle whenever someone attacks. Open up melee_combat_system.rs, and we'll add ParticleBuilder to the list of requested resources for the system. First, the includes:
If you cargo run now, you'll see a relatively subtle particle feedback to show that melee combat occurred. This definitely helps with the feel of gameplay, and is sufficiently non-intrusive that we aren't making our other systems too confusing.
It would be great to add similar effects to item use, so lets do it! In inventory_system.rs, we'll expand the ItemUseSystem introduction to include the ParticleBuilder:
We can use a similar effect for confusion - only with a magenta question mark. In the confusion section:
#![allow(unused)]fnmain() {
gamelog.entries.push(format!("You use {} on {}, confusing them.", item_name.name, mob_name.name));
let pos = positions.get(*mob);
ifletSome(pos) = pos {
particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::MAGENTA), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('?'), 200.0);
}
}
We should also use a particle to indicate that damage was inflicted. In the damage section of the system:
#![allow(unused)]fnmain() {
gamelog.entries.push(format!("You use {} on {}, inflicting {} hp.", item_name.name, mob_name.name, damage.damage));
let pos = positions.get(*mob);
ifletSome(pos) = pos {
particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::RED), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('‼'), 200.0);
}
}
Lastly, if an effect hits a whole area (for example, a fireball) it would be good to indicate what the area is. In the targeting section of the system, add:
#![allow(unused)]fnmain() {
for mob in map.tile_content[idx].iter() {
targets.push(*mob);
}
particle_builder.request(tile_idx.x, tile_idx.y, rltk::RGB::named(rltk::ORANGE), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('░'), 200.0);
}
That wasn't too hard, was it? If you cargo run your project now, you'll see various visual effects firing.
Lastly, we'll repeat the confused effect on monsters when it is their turn and they skip due to being confused. This should make it less confusing as to why they stand around. In monster_ai_system.rs, we first modify the system header to request the appropriate helper:
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 find a confusion scroll - you have visual feedback as to why a goblin isn't chasing you anymore:
That's it for visual effects 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!
The source code for this chapter may be found here