Part 9 - Ranged Scrolls and Targeting
In this part we’ll introduce ranged scrolls and targeting.
By the end of this part it will be possible to launch fireballs and confusion spells.
This part is loosely based on this part of the python tcod tutorial.
Reference implementation branch for starting point: part-8-end
In this post:
Examine Command
As a first step towards ranged abilities, add an examine command that lets th player use the arrow keys and mouse to move a cursor over the game area. We’ll add a section to the UI for showing the name of the character or item at the current cursor position.
We’ll also allow the player to use the mouse to examine a cell during normal gameplay.
Add a type enumerating all the different results of examining a cell.
// game.rs
...
#[derive(Clone, Copy, Debug)]
pub enum ExamineCell {
Npc(NpcType),
NpcCorpse(NpcType),
Item(ItemType),
Player,
}
...
Add a method to World
for examining a cell.
// world.rs
...
use crate::game::{ExamineCell, LogMessage};
...
impl World {
...
pub fn examine_cell(&self, coord: Coord) -> Option<ExamineCell> {
let layers = self.spatial_table.layers_at(coord)?;
layers
.character
.or_else(|| layers.object)
.and_then(|entity| {
self.components
.tile
.get(entity)
.and_then(|&tile| match tile {
Tile::Npc(npc_type) => Some(ExamineCell::Npc(npc_type)),
Tile::NpcCorpse(npc_type) => Some(ExamineCell::NpcCorpse(npc_type)),
Tile::Item(item_type) => Some(ExamineCell::Item(item_type)),
Tile::Player => Some(ExamineCell::Player),
_ => None,
})
})
}
}
Add a method to GameState
for examining a cell at a coordinate if it is currently visible to the player.
Also add a method returning the player’s current coordinate which will come in handy soon.
// game.rs
...
impl GameState {
...
pub fn player_coord(&self) -> Coord {
self.world
.entity_coord(self.player_entity)
.expect("player has no coord")
}
...
pub fn examine_cell(&self, coord: Coord) -> Option<ExamineCell> {
match self.visibility_grid.cell_visibility(coord) {
CellVisibility::Currently => self.world.examine_cell(coord),
_ => None,
}
}
}
Update the UI to have it render the currently-examined cell (if any). Also, when the cursor is controlled by the arrow keys, we’ll display a string to indicate what the cursor is for. Currently it will just be for examining cells, but later it will be for aiming spells as well.
// ui.rs
...
use crate::game::{ExamineCell, LogMessage};
use chargrid::{
decorator::{AlignView, Alignment, AlignmentX, AlignmentY, BoundView},
text::{wrap, RichTextPartOwned, RichTextViewSingleLine, StringView, StringViewSingleLine},
...
};
...
fn examine_cell_str(examine_cell: ExamineCell) -> &'static str {
match examine_cell {
ExamineCell::Npc(npc_type) | ExamineCell::NpcCorpse(npc_type) => npc_type.name(),
ExamineCell::Item(item_type) => item_type.name(),
ExamineCell::Player => "yourself",
}
}
...
pub struct UiData<'a> {
...
pub name: Option<&'static str>,
pub examine_cell: Option<ExamineCell>,
}
...
impl<'a> View<UiData<'a>> for UiView {
fn view<F: Frame, C: ColModify>(
&mut self,
data: UiData,
context: ViewContext<C>,
frame: &mut F,
) {
...
if let Some(name) = data.name {
BoundView {
size: Size::new(HEALTH_WIDTH, 1),
view: AlignView {
alignment: Alignment::centre(),
view: StringViewSingleLine::new(
Style::new().with_foreground(Rgb24::new_grey(255)),
),
},
}
.view(name, context.add_offset(Coord::new(0, 1)), frame);
}
if let Some(examine_cell) = data.examine_cell {
BoundView {
size: Size::new(HEALTH_WIDTH, 2),
view: AlignView {
alignment: Alignment {
x: AlignmentX::Centre,
y: AlignmentY::Bottom,
},
view: StringView::new(
Style::new().with_foreground(Rgb24::new_grey(187)),
wrap::Word::new(),
),
},
}
.view(
examine_cell_str(examine_cell),
context.add_offset(Coord::new(0, 2)),
frame,
);
}
}
}
Add a field to AppState
containing the current cursor position if any.
// app.rs
...
struct AppData {
...
cursor: Option<Coord>,
}
impl AppData {
fn new(screen_size: Size, rng_seed: u64, visibility_algorithm: VisibilityAlgorithm) -> Self {
...
Self {
...
cursor: None,
}
}
}
...
Update AppView::render_ui
to take the name of the current cursor mode, and have it render the cursor
and pass the result of examining the cell under the cursor to the UI renderer.
// app.rs
use chargrid::{
render::{blend_mode, ColModify, ColModifyMap, Frame, Style, View, ViewCell, ViewContext},
}
...
impl AppView {
...
fn render_ui<F: Frame, C: ColModify>(
&mut self,
name: Option<&'static str>,
data: &AppData,
context: ViewContext<C>,
frame: &mut F,
) {
...
let examine_cell = if let Some(cursor) = data.cursor {
frame.blend_cell_background_relative(
cursor,
1,
Rgb24::new_grey(255),
127,
blend_mode::LinearInterpolate,
context,
);
data.game_state.examine_cell(cursor)
} else {
None
};
self.ui_view.view(
UiData {
player_hit_points,
messages,
name,
examine_cell,
},
context.add_offset(Coord::new(0, self.ui_y_offset)),
frame,
);
}
}
...
Now go update all the places where AppView::render_ui
gets called and pass None
as its name
argument.
Update AppData::handle_input
so that moving the mouse during normal gameplay sets the cursor position,
and pressing a key clears the cursor. This will let the player use the mouse to examine cells, even when
not in “examine” mode.
// app.rs
...
use chargrid::{
...
input::{keys, Input, KeyboardInput, MouseButton, MouseInput},
...
}
...
impl AppData {
...
fn handle_input(&mut self, input: Input) -> Option<GameReturn> {
match input {
Input::Keyboard(key) => {
match key {
...
}
self.cursor = None;
}
Input::Mouse(mouse_input) => match mouse_input {
MouseInput::MouseMove { coord, .. } => self.cursor = Some(coord),
_ => (),
},
}
...
}
}
Add TargetEventRoutine
- an EventRoutine
in which the cursor can be controlled using the arrow keys as well
as the mouse. It has a string field which is the name of the target mode. This is the string that we’ll show in
the bottom-left corner.
// app.rs
...
struct TargetEventRoutine {
name: &'static str,
}
impl EventRoutine for TargetEventRoutine {
type Return = Option<Coord>;
type Data = AppData;
type View = AppView;
type Event = CommonEvent;
fn handle<EP>(
self,
data: &mut Self::Data,
_view: &Self::View,
event_or_peek: EP,
) -> Handled<Self::Return, Self>
where
EP: EventOrPeek<Event = Self::Event>,
{
event_routine::event_or_peek_with_handled(event_or_peek, self, |s, event| {
match event {
CommonEvent::Input(input) => match input {
Input::Keyboard(key) => {
let delta = match key {
KeyboardInput::Left => Coord::new(-1, 0),
KeyboardInput::Right => Coord::new(1, 0),
KeyboardInput::Up => Coord::new(0, -1),
KeyboardInput::Down => Coord::new(0, 1),
keys::RETURN => {
let cursor = data.cursor;
data.cursor = None;
return Handled::Return(cursor);
}
keys::ESCAPE => {
data.cursor = None;
return Handled::Return(None);
}
_ => Coord::new(0, 0),
};
data.cursor = Some(
data.cursor
.unwrap_or_else(|| data.game_state.player_coord())
+ delta,
);
}
Input::Mouse(mouse_input) => match mouse_input {
MouseInput::MouseMove { coord, .. } => data.cursor = Some(coord),
MouseInput::MousePress {
button: MouseButton::Left,
coord,
} => {
data.cursor = None;
return Handled::Return(Some(coord));
}
_ => (),
},
},
CommonEvent::Frame(_period) => (),
};
Handled::Continue(s)
})
}
fn view<F, C>(
&self,
data: &Self::Data,
view: &mut Self::View,
context: ViewContext<C>,
frame: &mut F,
) where
F: Frame,
C: ColModify,
{
view.game_view.view(&data.game_state, context, frame);
view.render_ui(Some(self.name), &data, context, frame);
}
}
...
Now update AppData::handle_input
again so that when the ‘x’ key is pressed, we run the TargetEventRoutine
so the player can examine cells moving the cursor with the arrow keys.
// app.rs
...
enum GameReturn {
...
Examine,
}
...
impl AppData {
...
fn handle_input(&mut self, input: Input) -> Option<GameReturn> {
match input {
Input::Keyboard(key) => {
match key {
KeyboardInput::Char('x') => {
if self.cursor.is_none() {
self.cursor = Some(self.game_state.player_coord());
}
return Some(GameReturn::Examine);
}
...
}
...
}
...
}
...
}
}
...
fn game_loop() -> impl EventRoutine<Return = (), Data = AppData, View = AppView, Event = CommonEvent>
{
make_either!(Ei = A | B | C | D | E);
Loop::new(|| {
GameEventRoutine.and_then(|game_return| match game_return {
...
GameReturn::Examine => Ei::E(TargetEventRoutine { name: "EXAMINE" }.map(|_| None)),
})
}).return_on_exit(|_| ())
}
...
Reference implementation branch: part-9.0
Fireball Scroll
The first ranged item we’ll add will be fireball scrolls. Add FireballScroll
as a new item type.
// world.rs
...
pub enum ItemType {
...
FireballScroll,
}
impl ItemType {
pub fn name(self) -> &'static str {
match self {
...
Self::FireballScroll => "fireball scroll",
}
}
}
impl World {
...
pub fn maybe_use_item(
&mut self,
character: Entity,
inventory_index: usize,
message_log: &mut Vec<LogMessage>,
) -> Result<(), ()> {
...
match item_type {
...
ItemType::FireballScroll => {
println!("todo");
}
}
Ok(())
}
...
}
// app.rs
...
pub mod colours {
...
pub const FIREBALL_SCROLL: Rgb24 = Rgb24::new(255, 127, 0);
...
pub fn item_colour(item_type: ItemType) -> Rgb24 {
match item_type {
...
ItemType::FireballScroll => FIREBALL_SCROLL,
}
}
}
fn currently_visible_view_cell_of_tile(tile: Tile) -> ViewCell {
match tile {
...
Tile::Item(ItemType::FireballScroll) => ViewCell::new()
.with_character('♫')
.with_foreground(colours::FIREBALL_SCROLL),
}
}
...
Update the dungeon generator to place fireball scrolls. Generalize the logic which places health potions to place all items. For now gives fireball scrolls a 100% chance of spawning to make it easier to test.
// terrain.rs
...
impl Room {
...
// Place `n` items at random positions within the room
fn place_items<R: Rng>(&self, n: usize, grid: &mut Grid<Option<TerrainTile>>, rng: &mut R) {
for coord in self
.coords()
.filter(|&coord| grid.get_checked(coord).unwrap() == TerrainTile::Floor)
.choose_multiple(rng, n)
{
let item = match rng.gen_range(0..100) {
0..=100 => ItemType::FireballScroll,
_ => ItemType::HealthPotion,
};
*grid.get_checked_mut(coord) = Some(TerrainTile::Item(item));
}
}
}
pub fn generate_dungeon<R: Rng>(size: Size, rng: &mut R) -> Grid<TerrainTile> {
...
const ITEMS_PER_ROOM_DISTRIBUTION: &[usize] = &[0, 0, 1, 1, 1, 1, 1, 2, 2];
...
for _ in 0..NUM_ATTEMPTS {
...
if room.only_intersects_empty(&grid) {
...
// Add items to the room
let &num_items = ITEMS_PER_ROOM_DISTRIBUTION.choose(rng).unwrap();
room.place_items(num_items, &mut grid, rng);
}
}
...
}
At this point you should be able to pick up fireball scrolls. When you use them the game will just print the text “todo” to stdout.
Reference implementation branch: part-9.1
Launching Fireballs
Now let’s make it possible to shoot fireballs when a fireball scroll is read. Rather than just teleporting the fireball to its target, let’s animate it moving along its trajectory.
In the previous section we just printed “todo” when a fireball scroll was used. Instead, we’d like the game to bring up the targeting AI, and when the user selects a target, shoot a fireball towards it. If the fireball hits a solid object along the way it should stop, and if the solid object is a character they should take damage.
When a health potion is used it is used immediately, but when a fireball scroll is used we display a UI. Let’s codify the different ways in which an item can be used:
// world.rs
...
#[derive(Clone, Copy)]
pub enum ItemUsage {
Immediate,
Aim,
}
...
Update item-usage methods to return the ItemUsage
of the item being used.
Previously we made the assumption that when an item is used, it is immediately
removed from the inventory, but this is only true for items whose usage is Immediate
.
Update maybe_use_item
to reflect this while we’re at it.
We’ll need to implement Inventory::get
.
// world.rs
...
impl Inventory {
...
pub fn get(&self, index: usize) -> Result<Entity, InventorySlotIsEmpty> {
self.slots
.get(index)
.cloned()
.flatten()
.ok_or(InventorySlotIsEmpty)
}
}
...
impl World {
...
pub fn maybe_use_item(
&mut self,
character: Entity,
inventory_index: usize,
message_log: &mut Vec<LogMessage>,
) -> Result<ItemUsage, ()> {
let inventory = self
.components
.inventory
.get_mut(character)
.expect("character has no inventory");
let item = match inventory.get(inventory_index) {
Ok(item) => item,
Err(InventorySlotIsEmpty) => {
message_log.push(LogMessage::NoItemInInventorySlot);
return Err(());
}
};
let &item_type = self
.components
.item
.get(item)
.expect("non-item in inventory");
let usage = match item_type {
ItemType::HealthPotion => {
let mut hit_points = self
.components
.hit_points
.get_mut(character)
.expect("character has no hit points");
const HEALTH_TO_HEAL: u32 = 5;
hit_points.current = hit_points.max.min(hit_points.current + HEALTH_TO_HEAL);
inventory.remove(inventory_index).unwrap();
message_log.push(LogMessage::PlayerHeals);
ItemUsage::Immediate
}
ItemType::FireballScroll => ItemUsage::Aim,
};
Ok(usage)
}
...
}
// game.rs
...
use crate::world::{
HitPoints, Inventory, ItemType, ItemUsage, Location, NpcType, Populate, ProjectileType, Tile,
World,
};
...
impl GameState {
...
pub fn maybe_player_use_item(&mut self, inventory_index: usize) -> Result<ItemUsage, ()> {
let result =
self.world
.maybe_use_item(self.player_entity, inventory_index, &mut self.message_log);
if let Ok(usage) = result {
match usage {
ItemUsage::Immediate => self.ai_turn(),
ItemUsage::Aim => (),
}
}
result
}
...
}
Update the use_item()
EventRoutine
to invoke the target EventRoutine
when the player uses an item
whose usage is Aim
. Note the not-yet-implemented GameState::maybe_player_use_item_aim
being called here,
which will actually launch the fireball.
// app.rs
...
use chargrid::{
...
event_routine::{
self,
common_event::{CommonEvent, Delay},
make_either, DataSelector, Decorate, EventOrPeek, EventRoutine, EventRoutineView, Handled,
Loop, SideEffect, SideEffectThen, Value, ViewSelector,
},
...
};
...
fn use_item() -> impl EventRoutine<Return = (), Data = AppData, View = AppView, Event = CommonEvent>
{
make_either!(Ei = A | B);
Loop::new(|| {
inventory_slot_menu("Use Item").and_then(|result| match result {
Err(menu::Escape) => Ei::A(Value::new(Some(()))),
Ok(entry) => Ei::B(SideEffectThen::new_with_view(
move |data: &mut AppData, _: &_| {
make_either!(Ei = A | B | C);
if let Ok(usage) = data.game_state.maybe_player_use_item(entry.index) {
match usage {
ItemUsage::Immediate => Ei::A(Value::new(Some(()))),
ItemUsage::Aim => Ei::B(TargetEventRoutine { name: "AIM" }.and_then(
move |maybe_coord| {
SideEffect::new_with_view(move |data: &mut AppData, _: &_| {
if let Some(coord) = maybe_coord {
if data
.game_state
.maybe_player_use_item_aim(entry.index, coord)
.is_ok()
{
Some(())
} else {
None
}
} else {
None
}
})
},
)),
}
} else {
Ei::C(Value::new(None))
}
},
)),
})
})
}
...
Implement GameState::maybe_player_use_item_aim
.
// game.rs
...
impl GameState {
...
pub fn maybe_player_use_item_aim(
&mut self,
inventory_index: usize,
target: Coord,
) -> Result<(), ()> {
self.world.maybe_use_item_aim(
self.player_entity,
inventory_index,
target,
&mut self.message_log,
)
}
...
}
And implement World::maybe_use_item_aim
. This function assumes it’s called on a sensible
item (e.g. you don’t try to aim a health potion). The game is implemented such that it should
be impossible to call this method on an invalid item, so this function panics in this case.
Should that panic ever execute, a bug has occurred at some point prior, and we shouldn’t try
to continue running the game.
// world.rs
...
impl World {
...
pub fn maybe_use_item_aim(
&mut self,
character: Entity,
inventory_index: usize,
target: Coord,
message_log: &mut Vec<LogMessage>,
) -> Result<(), ()> {
let character_coord = self.spatial_table.coord_of(character).unwrap();
if character_coord == target {
return Err(());
}
let inventory = self
.components
.inventory
.get_mut(character)
.expect("character has no inventory");
let item_entity = inventory.remove(inventory_index).unwrap();
let &item_type = self.components.item.get(item_entity).unwrap();
match item_type {
ItemType::HealthPotion => panic!("invalid item for aim"),
ItemType::FireballScroll => {
message_log.push(LogMessage::PlayerLaunchesProjectile(
ProjectileType::Fireball,
));
self.spawn_projectile(character_coord, target, ProjectileType::Fireball);
}
}
Ok(())
}
...
}
Two things in the above code haven’t been defined yet:
- the
LogMessage::PlayerLaunchesProjectile
variant - the
World::spawn_projectile
method
Add the log message types.
// game.rs
...
use crate::world::{
HitPoints, Inventory, ItemType, ItemUsage, Location, NpcType, Populate, ProjectileType, Tile,
World,
};
...
pub enum LogMessage {
...
PlayerLaunchesProjectile(ProjectileType),
}
...
This depends on a new type ProjectileType
. Add it to world.rs
.
// world.rs
...
#[derive(Clone, Copy, Debug)]
pub enum ProjectileType {
Fireball,
}
impl ProjectileType {
pub fn name(self) -> &'static str {
match self {
Self::Fireball => "fireball",
}
}
}
...
Handle the new type of log message.
// ui.rs
...
impl<'a> View<&'a [LogMessage]> for MessagesView {
fn view<F: Frame, C: ColModify>(
&mut self,
messages: &'a [LogMessage],
context: ViewContext<C>,
frame: &mut F,
) {
fn format_message(buf: &mut [RichTextPartOwned], message: LogMessage) {
...
match message {
...
PlayerLaunchesProjectile(projectile) => {
write!(&mut buf[0].text, "You launch a ").unwrap();
write!(&mut buf[1].text, "{}", projectile.name()).unwrap();
buf[1].style.foreground = Some(colours::projectile_colour(projectile));
write!(&mut buf[2].text, "!").unwrap();
}
}
}
...
}
}
...
This depends on colour::projectile_colour
. Define it.
// app.rs
...
use crate::world::{ItemType, ItemUsage, Layer, NpcType, ProjectileType, Tile};
...
pub mod colours {
pub fn projectile_colour(projcetile_type: ProjectileType) -> Rgb24 {
match projcetile_type {
ProjectileType::Fireball => FIREBALL_SCROLL,
}
}
}
...
Jumping all over the codebase today.
Back in world.rs
, define the spawn_projectile
method.
Add a projectile
component storing a ProjectileType
, a Projectile
tile, and a projcetile
layer.
Also add a trajectory
component for storing the motion path of a projectile.
// world.rs
...
use line_2d::CardinalStepIter;
...
pub enum Tile {
...
Projectile(ProjectileType),
}
entity_table::declare_entity_module! {
components {
...
trajectory: CardinalStepIter,
projectile: ProjectileType,
}
}
...
spatial_table::declare_layers_module! {
layers {
...
projectile: Projectile,
}
}
...
impl World {
...
fn spawn_projectile(&mut self, from: Coord, to: Coord, projectile_type: ProjectileType) {
let entity = self.entity_allocator.alloc();
self.spatial_table
.update(
entity,
Location {
coord: from,
layer: Some(Layer::Projectile),
},
)
.unwrap();
self.components
.tile
.insert(entity, Tile::Projectile(projectile_type));
self.components.projectile.insert(entity, projectile_type);
self.components
.trajectory
.insert(entity, CardinalStepIter::new(to - from));
}
...
}
Note the CardinalStepIter
type. This is an iterator over the coordinates along
a line segment between 2 points, only taking steps in cardinal directions.
We’ll use it to compute the path followed by a projectile.
Handle the new tile type and new layer in app.rs
:
// app.rs
...
impl<'a> View<&'a GameState> for GameView {
fn view<F: Frame, C: ColModify>(
&mut self,
game_state: &'a GameState,
context: ViewContext<C>,
frame: &mut F,
) {
for entity_to_render in game_state.entities_to_render() {
...
let depth = match entity_to_render.location.layer {
...
Some(Layer::Projectile) => 4,
};
...
}
}
}
...
fn currently_visible_view_cell_of_tile(tile: Tile) -> ViewCell {
match tile {
...
Tile::Projectile(ProjectileType::Fireball) => ViewCell::new()
.with_character('*')
.with_foreground(colours::FIREBALL_SCROLL),
}
}
...
Add a method to World
for moving all projectiles one step along their motion path.
// world.rs
...
impl World {
...
pub fn move_projectiles(&mut self, message_log: &mut Vec<LogMessage>) {
let mut entities_to_remove = Vec::new();
let mut fireball_hit = Vec::new();
for (entity, trajectory) in self.components.trajectory.iter_mut() {
if let Some(direction) = trajectory.next() {
let current_coord = self.spatial_table.coord_of(entity).unwrap();
let new_coord = current_coord + direction.coord();
let dest_layers = self.spatial_table.layers_at_checked(new_coord);
if dest_layers.feature.is_some() {
entities_to_remove.push(entity);
} else if let Some(character) = dest_layers.character {
entities_to_remove.push(entity);
if let Some(&projectile_type) = self.components.projectile.get(entity) {
match projectile_type {
ProjectileType::Fireball => {
fireball_hit.push(character);
}
}
}
}
// ignore collisiosns of projectiles
let _ = self.spatial_table.update_coord(entity, new_coord);
} else {
entities_to_remove.push(entity);
}
}
for entity in entities_to_remove {
self.remove_entity(entity);
}
for entity in fireball_hit {
let maybe_npc = self.components.npc_type.get(entity).cloned();
if let Some(VictimDies) = self.character_damage(entity, 2) {
if let Some(npc) = maybe_npc {
message_log.push(LogMessage::NpcDies(npc));
}
}
}
}
...
}
This requires some generalizations of our combat logic,
in particular adding a character_damage
method, extracting this logic from character_bump_attack
.
// world.rs
...
impl World {
...
fn character_bump_attack(&mut self, victim: Entity) -> Option<VictimDies> {
self.character_damage(victim, 1)
}
fn character_damage(&mut self, victim: Entity, damage: u32) -> Option<VictimDies> {
if let Some(hit_points) = self.components.hit_points.get_mut(victim) {
hit_points.current = hit_points.current.saturating_sub(damage);
if hit_points.current == 0 {
self.character_die(victim);
return Some(VictimDies);
}
}
None
}
...
}
If an NPC is killed by a fireball, a new log message NpcDies
is generated.
Add it.
// game.rs
...
pub enum LogMessage {
...
NpcDies(NpcType),
}
...
And handle it.
// ui.rs
...
impl<'a> View<&'a [LogMessage]> for MessagesView {
fn view<F: Frame, C: ColModify>(
&mut self,
messages: &'a [LogMessage],
context: ViewContext<C>,
frame: &mut F,
) {
fn format_message(buf: &mut [RichTextPartOwned], message: LogMessage) {
...
match message {
...
NpcDies(npc_type) => {
write!(&mut buf[0].text, "The ").unwrap();
write!(&mut buf[1].text, "{}", npc_type.name()).unwrap();
buf[1].style.foreground = Some(colours::npc_colour(npc_type));
write!(&mut buf[2].text, " dies.").unwrap();
}
}
}
...
}
}
...
// world.rs
Add a method to World
for testing whether there are any projectiles.
We’re about to add a simple realtime animation system, and we want an easy way to check
whether any animations are in progress so controls can be ignored while animations are playing.
// world.rs
...
impl World {
...
pub fn has_projectiles(&self) -> bool {
!self.components.trajectory.is_empty()
}
...
}
Add animation methods to GameState
, and prevent the player from acting while animations are in progress.
// game.rs
...
impl GameState {
pub fn tick_animations(&mut self) {
self.world.move_projectiles(&mut self.message_log)
}
fn has_animations(&self) -> bool {
self.world.has_projectiles()
}
...
pub fn wait_player(&mut self) {
if self.has_animations() {
return;
}
...
}
pub fn maybe_move_player(&mut self, direction: CardinalDirection) {
if self.has_animations() {
return;
}
...
}
pub fn maybe_player_get_item(&mut self) {
if self.has_animations() {
return;
}
...
}
pub fn maybe_player_use_item(&mut self, inventory_index: usize) -> Result<ItemUsage, ()> {
if self.has_animations() {
return Err(());
}
...
}
...
}
Now we need to periodically tick animations by calling GameState::tick_animations
.
Let’s only progress animations during normal gameplay, at a rate of 30 FPS (regardless of the game’s actual framerate).
Since the game is likely running at a higher framerate, we need to keep track of the passage of time, and only progress
animations very 33ms. Game ticks are sent to EventRoutine
s in the form of CommonEvent::Frame(period)
events,
where period
is a std::time::Duration
containing the amount of time that has passed since the previous frame.
Ticks are generally synchronized to the display’s framerate, but this is not a necessity and you shouldn’t rely on it.
// app.rs
...
const BETWEEN_ANIMATION_TICKS: Duration = Duration::from_millis(33);
...
struct AppData {
...
until_next_animation_tick: Duration,
}
impl AppData {
fn new(screen_size: Size, rng_seed: u64, visibility_algorithm: VisibilityAlgorithm) -> Self {
...
Self {
...
until_next_animation_tick: Duration::from_millis(0),
}
}
...
}
...
impl EventRoutine for GameEventRoutine {
type Return = GameReturn;
type Data = AppData;
type View = AppView;
type Event = CommonEvent;
fn handle<EP>(
self,
data: &mut Self::Data,
_view: &Self::View,
event_or_peek: EP,
) -> Handled<Self::Return, Self>
where
EP: EventOrPeek<Event = Self::Event>,
{
event_routine::event_or_peek_with_handled(event_or_peek, self, |s, event| match event {
CommonEvent::Input(input) => {
...
}
CommonEvent::Frame(period) => {
if let Some(until_next_animation_tick) =
data.until_next_animation_tick.checked_sub(period)
{
data.until_next_animation_tick = until_next_animation_tick;
} else {
data.until_next_animation_tick = BETWEEN_ANIMATION_TICKS;
data.game_state.tick_animations();
}
Handled::Continue(s)
}
})
}
...
}
And that should do it. Try picking up a fireball scroll and using it via the inventory menu. You’ll be presented with an “AIM” target ui. Target an NPC and hit the enter key or press the left mouse button.
A fireball will appear, and in realtime move towards the NPC.
When it hits them, they’ll take damage and possibly die.
Reference implementation branch: part-9.2
Confusion Scroll
Add a ConfusionScroll
item, and Confusion
projectile.
// world.rs
...
pub enum ProjectileType {
...
Confusion,
}
...
impl ProjectileType {
pub fn name(self) -> &'static str {
match self {
...
Self::Confusion => "confusion spell",
}
}
}
pub enum ItemType {
...
ConfusionScroll,
}
impl ItemType {
pub fn name(self) -> &'static str {
match self {
...
Self::ConfusionScroll => "confusion scroll",
}
}
}
...
Add rendering logic for the new item and projectile.
// app.rs
...
pub mod colours {
...
pub const CONFUSION_SCROLL: Rgb24 = Rgb24::new(187, 0, 255);
...
pub fn item_colour(item_type: ItemType) -> Rgb24 {
match item_type {
...
ItemType::ConfusionScroll => CONFUSION_SCROLL,
}
}
pub fn projectile_colour(projcetile_type: ProjectileType) -> Rgb24 {
match projcetile_type {
...
ProjectileType::Confusion => CONFUSION_SCROLL,
}
}
}
...
fn currently_visible_view_cell_of_tile(tile: Tile) -> ViewCell {
match tile {
...
Tile::Item(ItemType::ConfusionScroll) => ViewCell::new()
.with_character('♫')
.with_foreground(colours::CONFUSION_SCROLL),
Tile::Projectile(ProjectileType::Fireball) => ViewCell::new()
.with_character('*')
.with_foreground(colours::FIREBALL_SCROLL),
Tile::Projectile(ProjectileType::Confusion) => ViewCell::new()
.with_character('*')
.with_foreground(colours::CONFUSION_SCROLL),
}
}
Place confusion scrolls during dungeon generation. Also rebalance the probabilities of items such that health potions may appear again.
// terrain.rs
...
impl Room {
...
// Place `n` items at random positions within the room
fn place_items<R: Rng>(&self, n: usize, grid: &mut Grid<Option<TerrainTile>>, rng: &mut R) {
for coord in self
.coords()
.filter(|&coord| grid.get_checked(coord).unwrap() == TerrainTile::Floor)
.choose_multiple(rng, n)
{
let item = match rng.gen_range(0..100) {
0..=29 => ItemType::FireballScroll,
30..=49 => ItemType::ConfusionScroll,
_ => ItemType::HealthPotion,
};
*grid.get_checked_mut(coord) = Some(TerrainTile::Item(item));
}
}
}
When a character becomes confused, they will move randomly for 5 turns. To keep track of the number of turns
until a confused character recovers, add a confusion_countdown
component. Entities which have this component
will be considered to be confused, and it will also track the time until recovery.
// world.rs
...
entity_table::declare_entity_module! {
components {
...
confusion_countdown: u32,
}
}
..
Set the UsageType
for confusion scrolls, spawn a projectile when a confusion scroll is used, and set what happens
when a confusion spell hits an NPC.
// world.rs
...
impl World {
...
pub fn maybe_use_item(
&mut self,
character: Entity,
inventory_index: usize,
message_log: &mut Vec<LogMessage>,
) -> Result<ItemUsage, ()> {
...
let usage = match item_type {
...
ItemType::FireballScroll | ItemType::ConfusionScroll => ItemUsage::Aim,
};
...
}
...
pub fn maybe_use_item_aim(
&mut self,
character: Entity,
inventory_index: usize,
target: Coord,
message_log: &mut Vec<LogMessage>,
) -> Result<(), ()> {
...
match item_type {
...
ItemType::ConfusionScroll => {
message_log.push(LogMessage::PlayerLaunchesProjectile(
ProjectileType::Confusion,
));
self.spawn_projectile(character_coord, target, ProjectileType::Confusion);
}
}
Ok(())
}
...
pub fn move_projectiles(&mut self, message_log: &mut Vec<LogMessage>) {
...
let mut confusion_hit = Vec::new();
for (entity, trajectory) in self.components.trajectory.iter_mut() {
if let Some(direction) = trajectory.next() {
if dest_layers.feature.is_some() {
...
} else if let Some(character) = dest_layers.character {
entities_to_remove.push(entity);
if let Some(&projectile_type) = self.components.projectile.get(entity) {
match projectile_type {
...
ProjectileType::Confusion => {
confusion_hit.push(character);
}
}
}
}
...
}
}
...
for entity in confusion_hit {
self.components.confusion_countdown.insert(entity, 5);
if let Some(&npc_type) = self.components.npc_type.get(entity) {
message_log.push(LogMessage::NpcBecomesConfused(npc_type));
}
}
}
}
Add and handle log messages for becoming confused and recovering.
// game.rs
...
pub enum LogMessage {
...
NpcBecomesConfused(NpcType),
NpcIsNoLongerConfused(NpcType),
}
...
// ui.rs
...
impl<'a> View<&'a [LogMessage]> for MessagesView {
fn view<F: Frame, C: ColModify>(
&mut self,
messages: &'a [LogMessage],
context: ViewContext<C>,
frame: &mut F,
) {
fn format_message(buf: &mut [RichTextPartOwned], message: LogMessage) {
...
match message {
...
NpcBecomesConfused(npc_type) => {
write!(&mut buf[0].text, "The ").unwrap();
write!(&mut buf[1].text, "{}", npc_type.name()).unwrap();
buf[1].style.foreground = Some(colours::npc_colour(npc_type));
write!(&mut buf[2].text, " is confused.").unwrap();
}
NpcIsNoLongerConfused(npc_type) => {
write!(&mut buf[0].text, "The ").unwrap();
write!(&mut buf[1].text, "{}", npc_type.name()).unwrap();
buf[1].style.foreground = Some(colours::npc_colour(npc_type));
write!(&mut buf[2].text, "'s confusion passes.").unwrap();
}
}
}
...
}
}
Now let’s update movement logic such that confused characters move in random directions.
To use the Rng
trait to select a random direction, we need to enable the optional rand
feature of the direction
crate.
# Cargo.toml
[dependencies]
...
direction = { version = "0.18", features = ["rand"] }
In game.rs
, start passing a rng to World::maybe_move_character
.
// game.rs
...
impl GameState {
...
pub fn maybe_move_player(&mut self, direction: CardinalDirection) {
...
self.world.maybe_move_character(
self.player_entity,
direction,
&mut self.message_log,
&mut self.rng,
);
...
}
...
fn ai_turn(&mut self) {
...
for (entity, agent) in self.ai_state.iter_mut() {
...
match npc_action {
...
NpcAction::Move(direction) => self.world.maybe_move_character(
entity,
direction,
&mut self.message_log,
&mut self.rng,
),
}
}
}
...
}
And update World::maybe_move_character
to take an rng as an argument and use it
to move characters randomly when they are confused, also decreasing, and eventually removing,
their confusion_countdown
component.
// world.rs
...
impl World {
...
pub fn maybe_move_character<R: Rng>(
&mut self,
character_entity: Entity,
direction: CardinalDirection,
message_log: &mut Vec<LogMessage>,
rng: &mut R,
) {
let character_coord = self
.spatial_table
.coord_of(character_entity)
.expect("character has no coord");
let direction = if let Some(confusion_countdown) = self
.components
.confusion_countdown
.get_mut(character_entity)
{
if *confusion_countdown == 0 {
self.components.confusion_countdown.remove(character_entity);
if let Some(&npc_type) = self.components.npc_type.get(character_entity) {
message_log.push(LogMessage::NpcIsNoLongerConfused(npc_type));
}
} else {
*confusion_countdown -= 1;
}
rng.gen()
} else {
direction
};
let new_character_coord = character_coord + direction.coord();
...
}
...
}
It’s now possible to launch confusion spells in the same way as you launch fireballs. NPCs hit with confusion spells move randomly for their next 5 turns.
Reference implementation branch: part-9.3