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.
In the last chapter, we added items and inventory - and a single item type, a health potion. Now we'll add a second item type: a scroll of magic missile, that lets you zap an entity at range.
In the last chapter, we pretty much wrote code to ensure that all items were healing potions. That got things going, but isn't very flexible. So we'll start by breaking down items into a few more component types. We'll start with a simple flag component, Consumable:
Having this item indicates that using it destroys it (consumed on use). So we replace the always-called entities.delete(useitem.item).expect("Delete failed"); in our PotionUseSystem (which we rename ItemUseSystem!) with:
#![allow(unused)]fnmain() {
let consumable = consumables.get(useitem.item);
match consumable {
None => {}
Some(_) => {
entities.delete(useitem.item).expect("Delete failed");
}
}
}
This is quite simple: check if the component has a Consumable tag, and destroy it if it does. Likewise, we can replace the Potion section with a ProvidesHealing to indicate that this is what the potion actually does. In components.rs:
So we're describing where it is, what it looks like, its name, denoting that it is an item, consumed on use, and provides 8 points of healing. This is nice and descriptive - and future items can mix/match. As we add components, the item system will become more and more flexible.
That neatly lays out the properties of what makes it tick: it has a position, an appearance, a name, it's an item that is destroyed on use, it has a range of 6 tiles and inflicts 8 points of damage. That's what I like about components: after a while, it sounds more like you are describing a blueprint for a device than writing many lines of code!
We'll go ahead and add them into the spawn list:
#![allow(unused)]fnmain() {
fnrandom_item(ecs: &mut World, x: i32, y: i32) {
let roll :i32;
{
letmut rng = ecs.write_resource::<RandomNumberGenerator>();
roll = rng.roll_dice(1, 2);
}
match roll {
1 => { health_potion(ecs, x, y) }
_ => { magic_missile_scroll(ecs, x, y) }
}
}
}
Replace the call to health_potion in the item spawning code with a call to random_item.
If you run the program (with cargo run) now, you'll find scrolls as well as potions lying around. The components system already provides quite a bit of functionality:
You can see them rendered on the map (thanks to the Renderable and Position)
You can pick them up and drop them (thank to Item)
You can list them in your inventory
You can call use on them, and they are destroyed: but nothing happens.
We want magic missile to be targeted: you activate it, and then have to select a victim. This will be another input mode, so we once again extend RunState in main.rs:
That naturally leads to actually writing gui::ranged_target. This looks complicated, but it's actually quite straightforward:
#![allow(unused)]fnmain() {
pubfnranged_target(gs : &mut State, ctx : &mut Rltk, range : i32) -> (ItemMenuResult, Option<Point>) {
let player_entity = gs.ecs.fetch::<Entity>();
let player_pos = gs.ecs.fetch::<Point>();
let viewsheds = gs.ecs.read_storage::<Viewshed>();
ctx.print_color(5, 0, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Select Target:");
// Highlight available target cellsletmut available_cells = Vec::new();
let visible = viewsheds.get(*player_entity);
ifletSome(visible) = visible {
// We have a viewshedfor idx in visible.visible_tiles.iter() {
let distance = rltk::DistanceAlg::Pythagoras.distance2d(*player_pos, *idx);
if distance <= range asf32 {
ctx.set_bg(idx.x, idx.y, RGB::named(rltk::BLUE));
available_cells.push(idx);
}
}
} else {
return (ItemMenuResult::Cancel, None);
}
// Draw mouse cursorlet mouse_pos = ctx.mouse_pos();
letmut valid_target = false;
for idx in available_cells.iter() { if idx.x == mouse_pos.0 && idx.y == mouse_pos.1 { valid_target = true; } }
if valid_target {
ctx.set_bg(mouse_pos.0, mouse_pos.1, RGB::named(rltk::CYAN));
if ctx.left_click {
return (ItemMenuResult::Selected, Some(Point::new(mouse_pos.0, mouse_pos.1)));
}
} else {
ctx.set_bg(mouse_pos.0, mouse_pos.1, RGB::named(rltk::RED));
if ctx.left_click {
return (ItemMenuResult::Cancel, None);
}
}
(ItemMenuResult::NoResponse, None)
}
}
So we start by obtaining the player's location and viewshed, and iterating cells they can see. We check the range of the cell versus the range of the item, and if it is in range - we highlight the cell in blue. We also maintain a list of what cells are possible to target. Then, we get the mouse position; if it is pointing at a valid target, we light it up in cyan - otherwise we use red. If you click a valid cell, it returns targeting information for where you are aiming - otherwise, it cancels.
Now we extend our ShowTargeting code to handle this:
So now when you receive a WantsToUseItem, you can now that the user is the owning entity, the item is the item field, and it is aimed at target - if there is one (targeting doesn't make much sense for healing potions!).
So now we can add another condition to our ItemUseSystem:
#![allow(unused)]fnmain() {
// If it inflicts damage, apply it to the target celllet item_damages = inflict_damage.get(useitem.item);
match item_damages {
None => {}
Some(damage) => {
let target_point = useitem.target.unwrap();
let idx = map.xy_idx(target_point.x, target_point.y);
used_item = false;
for mob in map.tile_content[idx].iter() {
SufferDamage::new_damage(&mut suffer_damage, *mob, damage.damage);
if entity == *player_entity {
let mob_name = names.get(*mob).unwrap();
let item_name = names.get(useitem.item).unwrap();
gamelog.entries.push(format!("You use {} on {}, inflicting {} hp.", item_name.name, mob_name.name, damage.damage));
}
used_item = true;
}
}
}
}
This checks to see if we have an InflictsDamage component on the item - and if it does, applies the damage to everyone in the targeted cell.
If you cargo run the game, you can now blast entities with your magic missile scrolls!
We'll add another scroll type - Fireball. It's an old favorite, and introduces AoE - Area of Effect - damage. We'll start by adding a component to indicate our intent:
Notice that it's basically the same - but we're adding an AreaOfEffect component to indicate that it is what we want. If you were to cargo run now, you'd see Fireball scrolls in the game - and they would inflict damage on a single entity. Clearly, we must fix that!
In our UseItemSystem, we'll build a new section to figure out a list of targets for an effect:
#![allow(unused)]fnmain() {
// Targetingletmut targets : Vec<Entity> = Vec::new();
match useitem.target {
None => { targets.push( *player_entity ); }
Some(target) => {
let area_effect = aoe.get(useitem.item);
match area_effect {
None => {
// Single target in tilelet idx = map.xy_idx(target.x, target.y);
for mob in map.tile_content[idx].iter() {
targets.push(*mob);
}
}
Some(area_effect) => {
// AoEletmut blast_tiles = rltk::field_of_view(target, area_effect.radius, &*map);
blast_tiles.retain(|p| p.x > 0 && p.x < map.width-1 && p.y > 0 && p.y < map.height-1 );
for tile_idx in blast_tiles.iter() {
let idx = map.xy_idx(tile_idx.x, tile_idx.y);
for mob in map.tile_content[idx].iter() {
targets.push(*mob);
}
}
}
}
}
}
}
This says "if there is no target, apply it to the player". If there is a target, check to see if it is an Area of Effect event; if it is - plot a viewshed from that point of the appropriate radius, and add every entity in the target area. If it isn't, we just get the entities in the target tile.
So now we need to make the effect code generic. We don't want to assume that effects are independent; later on, we may decide that zapping something with a scroll has all manner of effects! So for healing, it looks like this:
#![allow(unused)]fnmain() {
// If it heals, apply the healinglet item_heals = healing.get(useitem.item);
match item_heals {
None => {}
Some(healer) => {
for target in targets.iter() {
let stats = combat_stats.get_mut(*target);
ifletSome(stats) = stats {
stats.hp = i32::min(stats.max_hp, stats.hp + healer.heal_amount);
if entity == *player_entity {
gamelog.entries.push(format!("You use the {}, healing {} hp.", names.get(useitem.item).unwrap().name, healer.heal_amount));
}
}
}
}
}
}
The damage code is actually simplified, since we've already calculated targets:
#![allow(unused)]fnmain() {
// If it inflicts damage, apply it to the target celllet item_damages = inflict_damage.get(useitem.item);
match item_damages {
None => {}
Some(damage) => {
used_item = false;
for mob in targets.iter() {
SufferDamage::new_damage(&mut suffer_damage, *mob, damage.damage);
if entity == *player_entity {
let mob_name = names.get(*mob).unwrap();
let item_name = names.get(useitem.item).unwrap();
gamelog.entries.push(format!("You use {} on {}, inflicting {} hp.", item_name.name, mob_name.name, damage.damage));
}
used_item = true;
}
}
}
}
If you cargo run the project now, you can use magic missile scrolls, fireball scrolls and health potions.
Let's add another item - confusion scrolls. These will target a single entity at range, and make them Confused for a few turns - during which time they will do nothing. We'll start by describing what we want in the item spawning code:
That's enough to have them appear, be triggerable and cause targeting to happen - but nothing will happen when it is used. We'll add the ability to pass along confusion to the ItemUseSystem:
#![allow(unused)]fnmain() {
// Can it pass along confusion? Note the use of scopes to escape from the borrow checker!letmut add_confusion = Vec::new();
{
let causes_confusion = confused.get(useitem.item);
match causes_confusion {
None => {}
Some(confusion) => {
used_item = false;
for mob in targets.iter() {
add_confusion.push((*mob, confusion.turns ));
if entity == *player_entity {
let mob_name = names.get(*mob).unwrap();
let item_name = names.get(useitem.item).unwrap();
gamelog.entries.push(format!("You use {} on {}, confusing them.", item_name.name, mob_name.name));
}
}
}
}
}
for mob in add_confusion.iter() {
confused.insert(mob.0, Confusion{ turns: mob.1 }).expect("Unable to insert status");
}
}
Alright! Now we can add the Confused status to anything. We should update the monster_ai_system to use it. Replace the loop with: