In this chapter we'll add the ability to target enemies at range using various scroll items. This will involve creating new input handlers, new items, and a new AI type. We'll also add some UI handling for displaying to the player where they are targeting. However, before we add all that new functionality, we have some housekeeping to take care of in the form of refactoring once again.
As I worked on this chapter I became increasingly aware of how much code was getting added to the input-handler.ts
file.
Most of the code we've been adding in there has to do with actions and not actual input handling. In the beginning of this
series it made sense since actions were just a result of handling input. Now that we've added items and AI, actions
can be produced as a result of several things, not just a key being pressed. So to start let's move all the actions out
of input-handler.ts
and into their own file. Create a new actions.ts
file and add the below:
import { Actor, Entity, Item } from './entity';
import { Colors } from './colors';
export abstract class Action {
abstract perform(entity: Entity): void;
}
export class PickupAction extends Action {
perform(entity: Entity) {
const consumer = entity as Actor;
if (!consumer) return;
const { x, y, inventory } = consumer;
for (const item of window.engine.gameMap.items) {
if (x === item.x && y == item.y) {
if (inventory.items.length >= inventory.capacity) {
window.engine.messageLog.addMessage(
'Your inventory is full.',
Colors.Impossible,
);
throw new Error('Your inventory is full.');
}
window.engine.gameMap.removeEntity(item);
item.parent = inventory;
inventory.items.push(item);
window.engine.messageLog.addMessage(`You picked up the ${item.name}!`);
return;
}
}
window.engine.messageLog.addMessage(
'There is nothing here to pick up.',
Colors.Impossible,
);
throw new Error('There is nothing here to pick up.');
}
}
export class ItemAction extends Action {
constructor(public item: Item) {
super();
}
perform(entity: Entity) {
this.item.consumable.activate(this, entity);
}
}
export class WaitAction extends Action {
perform(_entity: Entity) {}
}
export abstract class ActionWithDirection extends Action {
constructor(public dx: number, public dy: number) {
super();
}
perform(_entity: Entity) {}
}
export class MovementAction extends ActionWithDirection {
perform(entity: Entity) {
const destX = entity.x + this.dx;
const destY = entity.y + this.dy;
if (!window.engine.gameMap.isInBounds(destX, destY)) {
window.engine.messageLog.addMessage(
'That way is blocked.',
Colors.Impossible,
);
throw new Error('That way is blocked.');
}
if (!window.engine.gameMap.tiles[destY][destX].walkable) {
window.engine.messageLog.addMessage(
'That way is blocked.',
Colors.Impossible,
);
throw new Error('That way is blocked.');
}
if (window.engine.gameMap.getBlockingEntityAtLocation(destX, destY)) {
window.engine.messageLog.addMessage(
'That way is blocked.',
Colors.Impossible,
);
throw new Error('That way is blocked.');
}
entity.move(this.dx, this.dy);
}
}
export class BumpAction extends ActionWithDirection {
perform(entity: Entity) {
const destX = entity.x + this.dx;
const destY = entity.y + this.dy;
if (window.engine.gameMap.getActorAtLocation(destX, destY)) {
return new MeleeAction(this.dx, this.dy).perform(entity as Actor);
} else {
return new MovementAction(this.dx, this.dy).perform(entity);
}
}
}
export class MeleeAction extends ActionWithDirection {
perform(actor: Actor) {
const destX = actor.x + this.dx;
const destY = actor.y + this.dy;
const target = window.engine.gameMap.getActorAtLocation(destX, destY);
if (!target) {
window.engine.messageLog.addMessage(
'Nothing to attack',
Colors.Impossible,
);
throw new Error('Nothing to attack.');
}
const damage = actor.fighter.power - target.fighter.defense;
const attackDescription = `${actor.name.toUpperCase()} attacks ${
target.name
}`;
const fg =
actor.name === 'Player' ? Colors.PlayerAttack : Colors.EnemyAttack;
if (damage > 0) {
window.engine.messageLog.addMessage(
`${attackDescription} for ${damage} hit points.`,
fg,
);
target.fighter.hp -= damage;
} else {
window.engine.messageLog.addMessage(
`${attackDescription} but does no damage.`,
fg,
);
}
}
}
export class LogAction extends Action {
constructor(public moveLog: () => void) {
super();
}
perform(_entity: Entity) {
this.moveLog();
}
}
export class DropItem extends ItemAction {
perform(entity: Entity) {
const dropper = entity as Actor;
if (!dropper) return;
dropper.inventory.drop(this.item);
}
}
We haven't changed much in these actions, just moved them to their own file so we know where to look when modifying
actions. In a larger game, it might make sense to take this even further and make actions
a directory with modules
representing specific kinds of actions (e.g. Log, Item, Combat). For our little tutorial though, this will suffice.
One change that it's worth to point out is in LogAction
. Before, this action was used to open the message log. As part
of this refactor, we'll repurpose this action to represent when we need to scroll the log. The constructor of this action
takes in a moveLog
function. This function takes no parameters and doesn't return anything. When an input handler
creates one of these actions, it will give a function that will get executed when the action is performed. We'll see a concrete
example of this when we write the code for handling message log input.
Since we've moved our actions, we now need to update anywhere we've imported them to use the new file name. Update
ai.ts
, and consumable.ts
to import from ./actions.ts
.
Next let's delete all the code from input-handler.ts
and we'll start replacing it. I'll explain these changes as
we go as this is the most important part of our refactor. We'll start with our imports:
import {
Action,
BumpAction,
DropItem,
LogAction,
PickupAction,
WaitAction,
} from './actions';
import { Colors } from './colors';
Since the actions are no longer defined in input-handler.ts
we need to import them. We also need the colors enum for
messages and some UI work. Next we'll create some interfaces that we'll use throughout our input handling:
interface LogMap {
[key: string]: number;
}
const LOG_KEYS: LogMap = {
ArrowUp: -1,
ArrowDown: 1,
};
interface DirectionMap {
[key: string]: [number, number];
}
const MOVE_KEYS: DirectionMap = {
// Arrow Keys
ArrowUp: [0, -1],
ArrowDown: [0, 1],
ArrowLeft: [-1, 0],
ArrowRight: [1, 0],
Home: [-1, -1],
End: [-1, 1],
PageUp: [1, -1],
PageDown: [1, 1],
// Numpad Keys
1: [-1, 1],
2: [0, 1],
3: [1, 1],
4: [-1, 0],
6: [1, 0],
7: [-1, -1],
8: [0, -1],
9: [1, -1],
// Vi keys
h: [-1, 0],
j: [0, 1],
k: [0, -1],
l: [1, 0],
y: [-1, -1],
u: [1, -1],
b: [-1, 1],
n: [1, 1],
};
export enum InputState {
Game,
Dead,
Log,
UseInventory,
DropInventory,
}
LogMap
is used for mapping key presses to amounts scrolled in our log. DirectionMap
is to be used for mapping key
presses to a direction something should be moved. InputState
should look familiar as it is a copy of the enum we have
in engine.ts
. I realized as I started work on this chapter that the "state" we were using to determine what to render
and how to handle updates in the engine was all tied to what input mode the game was currently in. Because of this, it
makes the most sense to contain this state in our input handlers. We'll use this enum throughout the rest of this section:
Next up we'll create an abstract class to represent a based input handler:
export abstract class BaseInputHandler {
nextHandler: BaseInputHandler;
protected constructor(public inputState: InputState = InputState.Game) {
this.nextHandler = this;
}
abstract handleKeyboardInput(event: KeyboardEvent): Action | null;
}
Instead of having a function for handling input, we're going to create a series of classes that all inherit from this
base class. Each "mode" of input will have its own class that we'll implement. The BaseInputHandler
class defaults the
inputState
to the Game
state. Subclasses of this handler will set their inputState
accordingly. We expose an instance
variable called nextHandler
that can be used to switch between handlers. We'll make use of this when we start creating
new handlers. The nice thing about this variable is it allows a way for us to give a new handler to our engine, while
keeping the return type of handleKeyboardInput
simple. This means handleKeyboardInput
can just focus on returning
Action
types.
Next we can create our first subclass of this base handler:
export class GameInputHandler extends BaseInputHandler {
constructor() {
super();
}
handleKeyboardInput(event: KeyboardEvent): Action | null {
if (window.engine.player.fighter.hp > 0) {
if (event.key in MOVE_KEYS) {
const [dx, dy] = MOVE_KEYS[event.key];
return new BumpAction(dx, dy);
}
if (event.key === 'v') {
this.nextHandler = new LogInputHandler();
}
if (event.key === '5' || event.key === '.') {
return new WaitAction();
}
if (event.key === 'g') {
return new PickupAction();
}
if (event.key === 'i') {
this.nextHandler = new InventoryInputHandler(InputState.UseInventory);
}
if (event.key === 'd') {
this.nextHandler = new InventoryInputHandler(InputState.DropInventory);
}
}
return null;
}
}
This is the class that will handle input while we are in the default Game
state. Our constructor just calls the superclass
constructor to be sure all the base instance variables get set properly. Since we don't pass an InputState
to the super()
call, the state defaults to Game
.
In handleKeyboardInput
we check if the player is still alive, and if they are, we check what key they pressed. If they
pressed one of the movement keys we have mapped, we get the direction they should move from our mapping, and create a new
BumpAction
. For the keys mapped to opening the message log or using the inventory, we set our nextHandler
to a new instance
of a different subclass of BaseInputHandler
We'll create those throughout the rest of this section. The important thing
to understand here is that we can set nextHandler
and then the engine will update to use that handler accordingly.
export class LogInputHandler extends BaseInputHandler {
constructor() {
super(InputState.Log);
}
handleKeyboardInput(event: KeyboardEvent): Action | null {
if (event.key === 'Home') {
return new LogAction(() => (window.engine.logCursorPosition = 0));
}
if (event.key === 'End') {
return new LogAction(
() =>
(window.engine.logCursorPosition =
window.engine.messageLog.messages.length - 1),
);
}
const scrollAmount = LOG_KEYS[event.key];
if (!scrollAmount) {
this.nextHandler = new GameInputHandler();
}
return new LogAction(() => {
if (scrollAmount < 0 && window.engine.logCursorPosition === 0) {
window.engine.logCursorPosition =
window.engine.messageLog.messages.length - 1;
} else if (
scrollAmount > 0 &&
window.engine.logCursorPosition ===
window.engine.messageLog.messages.length - 1
) {
window.engine.logCursorPosition = 0;
} else {
window.engine.logCursorPosition = Math.max(
0,
Math.min(
window.engine.logCursorPosition + scrollAmount,
window.engine.messageLog.messages.length - 1,
),
);
}
});
}
}
In our LogInputHandler
constructor, we pass InputState.Log
to the superclass constructor. This makes sure that we
have the proper state set. The logic for handleKeyboardInput
is mostly similar to our prior handler. The big thing
to notice here is that instead of directly manipulating the cursor position, we are giving arrow functions to our LogAction
constructors. These functions will get called when the engine calls perform
on the returned action.
Next we'll create a handler for dealing with the inventory:
export class InventoryInputHandler extends BaseInputHandler {
constructor(inputState: InputState) {
super(inputState);
}
handleKeyboardInput(event: KeyboardEvent): Action | null {
if (event.key.length === 1) {
const ordinal = event.key.charCodeAt(0);
const index = ordinal - 'a'.charCodeAt(0);
if (index >= 0 && index <= 26) {
const item = window.engine.player.inventory.items[index];
if (item) {
this.nextHandler = new GameInputHandler();
if (this.inputState === InputState.UseInventory) {
return item.consumable.getAction();
} else if (this.inputState === InputState.DropInventory) {
return new DropItem(item);
}
} else {
window.engine.messageLog.addMessage('Invalid entry.', Colors.Invalid);
return null;
}
}
}
this.nextHandler = new GameInputHandler();
return null;
}
}
The constructor for InventoryInputHandler
takes in an InputState
parameter and passes it to the base class constructor.
If you look back to our GameInputHandler
class, when we instantiate this class, we pass either the UseInventory
or
DropInventory
values. Doing this means we don't have to have two separate classes for handling our inventory. In the
handleKeyboardInput
method, the code remains much the same as our prior handler. The big change here is instead of
directly setting the handler in the engine, we set the nextHandler
property.
Now that all our input handlers are created, we need to update engine.ts
to make use of them. We'll start with our imports:
import {
BaseInputHandler,
GameInputHandler,
InputState,
} from './input-handler';
import { Action } from './actions';
We import that base handler, the game handler, and the enum representing the input state. We also will need the Action
type.
At the top of our Engine
class, remove the declaration of the _state
instance variable and when we initialize it. We'll
be using the InputState
on the handler from now on. Add a new instance variable and initialize it for the input handler:
export class Engine {
// statics and other instance variable omitted for brevity
inputHandler: BaseInputHandler
constructor(public player: Actor) {
this.inputHandler = new GameInputHandler();
}
}
Next, remove the state
getter and setter functions as we won't be needing them. Then we can modify the update
method
to look like this:
update(event: KeyboardEvent) {
const action = this.inputHandler.handleKeyboardInput(event);
if (action instanceof Action) {
try {
action.perform(this.player);
this.handleEnemyTurns();
this.gameMap.updateFov(this.player);
} catch {}
}
this.inputHandler = this.inputHandler.nextHandler;
this.render();
}
Since we have standardized all our input handlers to return an action, we can simplify our update
code to perform
an action if one was returned. We then update our inputHandler
instance variable to point to the nextHandler
that was
set on the current handler. If the input handler stays the same, then nothing changes. However, if the current handler
has determined that further input should be processed by a different handler, this will allow that to happen.
Now we need to update our render
method to use the handler's inputState
property instead of the engine state we were
using before:
render() {
this.display.clear();
this.messageLog.render(this.display, 21, 45, 40, 5);
renderHealthBar(
this.display,
this.player.fighter.hp,
this.player.fighter.maxHp,
20,
);
renderNamesAtLocation(21, 44);
this.gameMap.render();
if (this.inputHandler.inputState === InputState.Log) {
renderFrameWithTitle(3, 3, 74, 38, 'Message History');
this.messageLog.renderMessages(
this.display,
4,
4,
72,
36,
this.messageLog.messages.slice(0, this.logCursorPosition + 1),
);
}
if (this.inputHandler.inputState === InputState.UseInventory) {
this.renderInventory('Select an item to use');
}
if (this.inputHandler.inputState === InputState.DropInventory) {
this.renderInventory('Select an item to drop');
}
}
The code here is the same as it was other than changing where we check the state. The last thing we need to do for this
refactor is remove the EngineState
enum from engine.ts
. Do that and then run the application. Everything should work
the same as it did before the refactor. You can find the complete code for this section here.
Now that we have our code cleaned up, we can start adding some more functionality to our game. The first thing we're going to add is the ability for our player to hit a monster with a bolt of lightning, dealing a large amount of damage. We'll create a new type of item that the player will use to do this. Using this "scroll" will automatically target the closest enemy to the player that is within their field of view.
We'll start by adding some new colors to our enum in `colors.ts:
export enum Colors {
White = '#ffffff',
Black = '#000000',
Red = '#ff0000',
PlayerAttack = '#e0e0e0',
EnemyAttack = '#ffc0c0',
NeedsTarget = '#3FFFFF',
StatusEffectApplied = '#3FFF3F',
PlayerDie = '#ff3030',
EnemyDie = '#ffa030',
WelcomeText = '#20a0ff',
BarFilled = '#006000',
BarEmpty = '#401010',
Invalid = '#ffff00',
Impossible = '#808080',
Error = '#ff4040',
HealthRecovered = '#00ff00',
}
Next we'll make a small change to our ItemAction
class in actions.ts
:
export class ItemAction extends Action {
constructor(public item: Item) {
super();
}
perform(entity: Entity) {
this.item.consumable.activate(entity);
}
}
We've updated the call to activate
to only pass the entity performing the action. Next let's add a new utility method
to the Entity
class:
distance(x: number, y: number) {
return Math.sqrt((x - this.x) ** 2 + (y - this.y) ** 2);
}
This method calculates the distance from the entity to a given x/y coordinate by applying the pythagorean theorem (a^2 + b^2 = c^2
).
Next we'll make some changes in consumable.ts
. We'll start by changing Consumable
from an interface to an abstract
class:
export abstract class Consumable {
protected constructor(public parent: Item | null) {}
getAction(): Action | null {
if (this.parent) {
return new ItemAction(this.parent);
}
return null;
}
abstract activate(entity: Entity): void;
consume() {
const item = this.parent;
if (item) {
const inventory = item.parent;
if (inventory instanceof Inventory) {
const index = inventory.items.indexOf(item);
if (index >= 0) {
inventory.items.splice(index, 1);
}
}
}
}
}
We do this because the logic of the constructor and the consume
event will be the same across our subclasses, so we can
contain that logic here in the base class as opposed to duplicating it across subclasses. We also update the activate
method signature to only take in the entity activating the consumable. With that change, we now need to update the
HealingConsumable
class:
export class HealingConsumable extends Consumable {
constructor(public amount: number, public parent: Item | null = null) {
super(parent);
}
activate(entity: Entity) {
// contents omitted for brevity
}
}
We change implements
to extends
now that we're dealing with a base class and update the constructor to pass the parent
to the superclass constructor. Other than updating the signature for activate
everything stays the same.
Now we can create our new consumable class for the lightning scroll. We'll start with the constructor:
export class LightningConsumable extends Consumable {
constructor(
public damage: number,
public maxRange: number,
parent: Item | null = nul,
) {
super(parent);
}
}
The constructor for our new consumable takes in the amount of damage it will deal and the maximum range that it will look
for a target. It also passes the parent
item along to the superclass. With that in place we can implement the activate
method of the consumable:
activate(entity: Entity) {
let target: Actor | null = null;
let closestDistance = this.maxRange + 1.0;
for (const actor of window.engine.gameMap.actors) {
if (
!Object.is(actor, entity) &&
window.engine.gameMap.tiles[actor.y][actor.x].visible
) {
const distance = entity.distance(actor.x, actor.y);
if (distance < closestDistance) {
target = actor;
closestDistance = distance;
}
}
}
if (target) {
window.engine.messageLog.addMessage(
`A lightning bolt strikes the ${target.name} with a loud thunder, for ${this.damage} damage!`,
);
target.fighter.takeDamage(this.damage);
this.consume();
} else {
window.engine.messageLog.addMessage(
'No enemy is close enough to strike.',
);
throw new Error('No enemy is close enough to strike.');
}
}
We start by setting a variable for a target. This starts as null because we'll search for a target and if one isn't found in range, we'll tell the player that it isn't possible to use the scroll without a target. We also set a variable for the closest distance we've found a target. It starts at our maximum range plus one tile. We add one tile so that we include any monsters who just fractionally outside the max range due to the way distance is calculated.
We then loop over each of the actors on the map and check if they are visible to the player, that they aren't actually the same entity as the player (wouldn't want to hit ourselves for big lightning damage!). If that's true, then we calculate the distance to the actor and if that distance is less than the closest distance we've found thus far, we set the target to the current actor, and update our closest distance found.
After looping through the actors, we check if we found a target. If so, we add a message to the log, apply damage to the target actor, and then consume the item. If not target was found, we add a message to the log and throw an error so that we don't perform any action this turn.
With our consumable created we can jump over to entity.ts
and create a spawn function for a new lightning scroll
item. Make sure you add LightningConsumable
to the imports first, then add our new function:
export function spawnLightningScroll(gameMap: GameMap, x: number, y: number) {
return new Item(
x,
y,
'~',
'#FFFF00',
'#000',
'Lightning Scroll',
new LightningConsumable(20, 5),
gameMap,
);
}
Our new lightning scroll will deal 20 damage to the closest target to the player that is at most five tiles away.
Last thing to do to get this new item working is to place it on the map in procgen.ts
. Update the for loop when we add
items to the map in the placeEntities
function like so:
for (let i = 0; i < numberOfItemsToAdd; i++) {
const x = generateRandomNumber(bounds.x1 + 1, bounds.x2 - 1);
const y = generateRandomNumber(bounds.y1 + 1, bounds.y2 - 1);
if (!dungeon.entities.some((e) => e.x == x && e.y == y)) {
if (Math.random() < 0.7) {
spawnHealthPotion(dungeon, x, y);
} else {
spawnLightningScroll(dungeon, x, y);
}
}
}
Run the game and move around the map to find a lightning scroll. You should be able to pick it up and use it just like the potions we did in the last chapter, but now you'll blast monsters instead of healing yourself! You can find the complete code for this section here.
Blasting baddies with lightning is cool, but we aren't targeting monsters directly. Before we can create items to do that,
let's add some functionality to look around the map using the keyboard. Most roguelike games include this. It won't do
anything that we can't already do with our mouse pointer, but we'll leverage this for our targeting functionality later.
Start by opening up input-handler.ts
and adding an import for the engine and a new state to our InputState
enum:
import { Engine } from './engine';
export enum InputState {
Game,
Dead,
Log,
UseInventory,
DropInventory,
Target,
}
We'll be using the static values on the Engine
class to get the width and height of our map and we've added a new Target
state to the enum. Next we'll add a new if statement in handleKeyboardInput
for the GameInputHandler
to enable
turning on our new "look" mode:
// rest of handler omitted for brevity
if (event.key === 'd') {
this.nextHandler = new InventoryInputHandler(InputState.DropInventory);
}
if (event.key === '/') {
this.nextHandler = new LookHandler();
}
Similar to the other mode switches, we set nextHandler
to a new instance of a handler when the slash key is pressed.
Before we create the LookHandler
class though, we'll create a subclass that will be based off of. Add this to the end
of the file:
export abstract class SelectIndexHandler extends BaseInputHandler {
protected constructor() {
super(InputState.Target);
const { x, y } = window.engine.player;
window.engine.mousePosition = [x, y];
}
handleKeyboardInput(event: KeyboardEvent): Action | null {
if (event.key in MOVE_KEYS) {
const moveAmount = MOVE_KEYS[event.key];
let modifier = 1;
if (event.shiftKey) modifier = 5;
if (event.ctrlKey) modifier = 10;
if (event.altKey) modifier = 20;
let [x, y] = window.engine.mousePosition;
const [dx, dy] = moveAmount;
x += dx * modifier;
y += dy * modifier;
x = Math.max(0, Math.min(x, Engine.MAP_WIDTH - 1));
y = Math.max(0, Math.min(y, Engine.MAP_HEIGHT - 1));
window.engine.mousePosition = [x, y];
return null;
} else if (event.key === 'Enter') {
return this.onIndexSelected();
}
this.nextHandler = new GameInputHandler();
return null;
}
abstract onIndexSelected(): Action | null;
}
This class will be the base for any targeting input we need to do. It contains all the logic needed for moving the cursor around and selecting a target. The subclasses of this class will then only need to focus on what to do when a target is selected.
The constructor sets the state to our new Target
state and then sets the mouse cursor position to where the player is.
In handleKeyboardInput
we get the direction pressed and then also check if they hit any of the shift, ctrl, or alt keys.
If they did, we multiply the amount moved by an amount determined by which key they pressed. We then update the mouse
position to where they moved, clamping the position so that it stays withing the bounds of the map.
If the player hit the Enter
key we delegate this to our onIndexSelected
method that will be implemented in subclasses.
If they hit a key that isn't a movement key or Enter
we just exit the mode and return to the game mode. With that in place,
we can create the LookHandler
class:
export class LookHandler extends SelectIndexHandler {
constructor() {
super();
}
onIndexSelected(): Action | null {
this.nextHandler = new GameInputHandler();
return null;
}
}
This class is very simple as all we want to do is look around the map. All we need to do in onIndexSelected
is update the
handler to be in "Game" mode.
The last thing we need to do to get our look mode working is update engine.ts
. Add this if statement to the end of the
render
method:
if (this.inputHandler.inputState === InputState.Target) {
const [x, y] = this.mousePosition;
const data = this.display._data[`${x},${y}`];
const char = data ? data[2] || ' ' : ' ';
this.display.drawOver(x, y, char[0], '#000', '#fff');
}
Here we check if the input handler is in target mode. If it is, we get the current mouse position and use that to extract
data from the display. This is an internal implementation detail of the ROT.js Display
class. It's usually not a great
idea to tie to internal details like this as a change in the implementation could break your code. However, ROT.js doesn't
currently have a way to modify the background of a tile or draw over a tile without losing some data contained in that
tile. The key into the _data
property on the display is a string in the form of "x,y"
where x and y are numbers representing
position in the display. If we get data back from the display, it is an array. You can see this in the api docs here.
The third index of that array contains the character we want to display as either a string or string array. If we didn't
get data back we set char
to an empty string. We then redraw that character with a new foreground and background color
to highlight our cursor position.
If you run the game now and press the slash key, you should see a white block where the cursor is. Use the move keys to move the cursor around the map. Moving it over items or monsters should display their name just like when we use the mouse. You can find the complete code for this section here.
Now that we have a base input mode for targeting, let's put it to use. First we'll do a little more cleanup. Whenever we
hit an impossible action, we print a message to the console and throw an error. This works for now, but when our AI attempts
an impossible action it will also print to the log. We only want the log to show things the player did. Create
a new file called exceptions.ts
and add the below to it:
export class ImpossibleException {
constructor(public message: string) {}
}
We'll use this exception class instead of the generic Error
for throwing when an impossible action occurs. Let's import
this exception in engine.ts
and then modify the update
method:
update(event: KeyboardEvent) {
const action = this.inputHandler.handleKeyboardInput(event);
if (action instanceof Action) {
try {
action.perform(this.player);
this.handleEnemyTurns();
this.gameMap.updateFov(this.player);
} catch (error) {
if (error instanceof ImpossibleException) {
this.messageLog.addMessage(error.message, Colors.Impossible);
}
}
}
this.inputHandler = this.inputHandler.nextHandler;
this.render();
}
We've updated the catch
block to handle any errors thrown and if they are ImpossibleException
s, add them to the
message log. With that done we no longer need to add to the message log in our actions and can just throw an exception
instead. Go through all the actions in actions.ts
and remove calls to window.engine.messageLog.addMessage
and change
the exceptions throw from Error
to ImpossibleException
. I won't detail all these changes here, but I'll include
a link to the complete code at the end of the section as always.
Still in actions.ts
let's update ItemAction
to be able to handle our targeting mode. First we'll update the constructor:
constructor(
public item: Item | null,
public targetPosition: [number, number] | null = null,
) {
super();
}
We've changed the type of item
to be nullable and added a new property that represents the position of a target. This is
also nullable since not every item is used for targeting something. Next let's add a getter to get the actor at the target
position:
public get targetActor(): Actor | undefined {
if (!this.targetPosition) {
return;
}
const [x, y] = this.targetPosition;
return window.engine.gameMap.getActorAtLocation(x, y);
}
This getter will find the actor at a given position and return it if one is found. Otherwise, it will return undefined
.
We also need to update the perform
method:
perform(entity: Entity) {
this.item?.consumable.activate(this, entity);
}
Now that item
is nullable we need to use the optional chaining operator to avoid any null pointer exceptions. We also
are updating the call to activate
to pass the action to the consumable so that it can access the target information.
Due to the change in ItemAction
we also need to update the DropItem
action to avoid issues:
export class DropItem extends ItemAction {
perform(entity: Entity) {
const dropper = entity as Actor;
if (!dropper || !this.item) return;
dropper.inventory.drop(this.item);
}
}
We just need to add the check for the item before we drop, so we don't try to drop a non-existent item.
Now we can start creating a new scroll for our player to use. We'll be creating a confusion scroll that the player can
use and target a specific enemy. The enemy will then be confused for a number of turns, blindly wandering around the map
instead of chasing the player. To start this, we'll create a new AI class for a confused enemy to use. Go over to ai.ts
and we'll add some new imports first:
import {
Action,
BumpAction,
MeleeAction,
MovementAction,
WaitAction,
} from '../actions';
import { generateRandomNumber } from '../procgen';
We'll also update the BaseAI
class constructor and perform
method:
export abstract class BaseAI implements Action {
path: [number, number][];
protected constructor() {
this.path = [];
}
abstract perform(entity: Entity): void;
// omitted rest of class for brevity
}
These changes aren't necessary, but it cleans things up and makes it clear that this class is abstract and needs to have certain functionality implemented by subclasses.
Next we'll add a constant that represents the directions an actor could move on the map. Add this to the end of the file:
const directions: [number, number][] = [
[-1, -1], // Northwest
[0, -1], // North
[1, -1], // Northeast
[-1, 0], // West
[1, 0], // East
[-1, 1], // Southwest
[0, 1], // South
[1, 1], // Southeast
];
Now we can create our new AI class:
export class ConfusedEnemy extends BaseAI {
constructor(public previousAi: BaseAI | null, public turnsRemaining: number) {
super();
}
}
Our ConfusedEnemy
will keep track of the previous ai type the actor had, so we can switch back when the effect ends. It
also keeps track of how many turns of confusion remain. Next let's implement the perform
method:
perform(entity: Entity) {
const actor = entity as Actor;
if (!actor) return;
if (this.turnsRemaining <= 0) {
window.engine.messageLog.addMessage(
`The ${entity.name} is no longer confused.`,
);
actor.ai = this.previousAi;
} else {
const [directionX, directionY] =
directions[generateRandomNumber(0, directions.length)];
this.turnsRemaining -= 1;
const action = new BumpAction(directionX, directionY);
action.perform(entity);
}
}
We first check if we have an actor and if not, return early from the method. Then we check if there are still turns of confusion remaining. If there aren't, we add a message to the log indicating the actor is no longer confused and reset the ai of the actor back to its previous ai. If there are still turns remaining, we get a random direction using our list of directions, and attempt to move in that direction. We then reduce the turns remaining by one.
Now let's jump over to input-handler.ts
. We'll start by updating our SelectIndexHandler
class to handle targeted
positions. Update the handleKeyboardInput
method when we handle an Enter
key press:
} else if (event.key === 'Enter') {
let [x, y] = window.engine.mousePosition;
return this.onIndexSelected(x, y);
}
We get the current position of the cursor and then pass that to our onIndexSelected
method. Let's update the signature
for that method now:
abstract onIndexSelected(x: number, y: number): Action | null;
We'll also need to update the signature in the LookHandler
as well:
onIndexSelected(_x: number, _y: number): Action | null {
this.nextHandler = new GameInputHandler();
return null;
}
We prepend underscores here because we won't be using the position in the look mode.
Next we'll create a new type that we'll use in for handling actions when a target is selected:
type ActionCallback = (x: number, y: number) => Action | null;
This type describes a function that takes in an x/y position and return either an Action
or null
. With this in place
we can now create a new input handler that will use it:
export class SingleRangedAttackHandler extends SelectIndexHandler {
constructor(public callback: ActionCallback) {
super();
}
onIndexSelected(x: number, y: number): Action | null {
this.nextHandler = new GameInputHandler();
return this.callback(x, y);
}
}
This new handler takes in an ActionCallback
. When a target is selected, we return to game mode and then call the callback
function that was provided with the x/y position of the selected target. We'll see how we use this callback here in a moment.
Let's jump over to consumable.ts
and add some imports:
import { SingleRangedAttackHandler } from '../input-handler';
import { ConfusedEnemy } from './ai';
import { ImpossibleException } from '../exceptions'
Next we'll update the signature of the activate
method on the Consumable
class:
abstract activate(action: ItemAction, entity: Entity): void;
We're taking in the ItemAction
now because some consumables will need to know the target of the action. Let's update the
signature in our HealingConsumable
and make use of the new exception we created as well:
activate(_action: ItemAction, entity: Entity) {
const consumer = entity as Actor;
if (!consumer) return;
const amountRecovered = consumer.fighter.heal(this.amount);
if (amountRecovered > 0) {
window.engine.messageLog.addMessage(
`You consume the ${this.parent?.name}, and recover ${amountRecovered} HP!`,
Colors.HealthRecovered,
);
this.consume();
} else {
throw new ImpossibleException('Your health is already full.');
}
}
We'll also do the same for the LightningConsumable
class:
activate(_action: ItemAction, entity: Entity) {
// rest of method omitted for brevity
} else {
window.engine.messageLog.addMessage(
'No enemy is close enough to strike.',
);
throw new ImpossibleException('No enemy is close enough to strike.');
}
}
Next we'll create a new consumable for our confusion scroll:
export class ConfusionConsumable extends Consumable {
constructor(public numberOfTurns: number, parent: Item | null = null) {
super(parent);
}
}
The ConfusionConsumable
takes in the number of turns it will apply confusion to a target. The getAction
method
for this consumable is different from others so let's implement that now:
getAction(): Action | null {
window.engine.messageLog.addMessage(
'Select a target location.',
Colors.NeedsTarget,
);
window.engine.inputHandler = new SingleRangedAttackHandler((x, y) => {
return new ItemAction(this.parent, [x, y]);
});
return null;
}
We add a message to the log telling the player they need to select a target. We then directly update the engine's input
handler to an instance of our new handler. We pass an arrow function to this handler that returns a new ItemAction
that needs
be performed when a target is selected. This arrow function is the callback that will get called by our handler when the
player confirms their target. We then return null since there's no action yet to be performed.
Now we can implement the activate
for this consumable:
activate(action: ItemAction, entity: Entity) {
const target = action.targetActor;
if (!target) {
throw new ImpossibleException('You must select an enemy to target.');
}
if (!window.engine.gameMap.tiles[target.y][target.x].visible) {
throw new ImpossibleException(
'You cannot target an area you cannot see.',
);
}
if (Object.is(target, entity)) {
throw new ImpossibleException('You cannot confuse yourself!');
}
window.engine.messageLog.addMessage(
`The eyes of the ${target.name} look vacant, as it starts to stumble around!`,
Colors.StatusEffectApplied,
);
target.ai = new ConfusedEnemy(target.ai, this.numberOfTurns);
this.consume();
}
We first try to get the target of the action. If there isn't a valid target we throw an exception so no action is taken. If we do have a valid target, we add a message to the log that the target has been confused. We then update the ai of the target to an instance of our new ai. Finally, we consume the item, so it is removed from the inventory.
Next we'll jump over to entity.ts
and create our spawn function for the confusion scroll. Remember to import the
ConfusionConsumable
at the top of the file. Then add our new function:
export function spawnConfusionScroll(gameMap: GameMap, x: number, y: number) {
return new Item(
x,
y,
'~',
'#cf3fff',
'#000',
'Confusion Scroll',
new ConfusionConsumable(10),
gameMap,
);
}
This item will confuse the target for 10 turns. Open up procgen.ts
and import the new spawn function at the top. Then
we'll update generateRandomNumber
to be exported so it can be used in our new ai class:
export function generateRandomNumber(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1) + min);
}
The last thing we need to do to get the new scroll to work is add them to the level. Update the item loop in placeEntities
like this:
if (!dungeon.entities.some((e) => e.x == x && e.y == y)) {
const itemChance = Math.random();
if (itemChance < 0.7) {
spawnHealthPotion(dungeon, x, y);
} else if (itemChance < 0.9) {
spawnConfusionScroll(dungeon, x, y);
} else {
spawnLightningScroll(dungeon, x, y);
}
}
Run the game and find one of our new scrolls. If you use the scroll, the game should switch to target mode. Move the cursor over to an enemy and press enter. They should now be confused and move around randomly until the scroll wears off. You can find the complete code for this section here.
We'll add one more item in this chapter: a fireball that does area-of-effect damage. Using this item will target an area
around the chosen location, and deal damage to all actors in the area. Even the player! We'll start it input-handler.ts
by
adding an import at the top of the file:
import { Display } from 'rot-js';
We'll be using the display in our new input handler to show the targeted area. Next we'll update our BaseInputHandler
to
have a new method:
onRender(_display: Display) {}
We add this do nothing method in the base class because most of our handlers won't need to do any rendering, but this way
our engine doesn't need to care about knowing that. It can just call the onRender
method and let the handlers worry
about the details.
Next we'll create a new handler:
export class AreaRangedAttackHandler extends SelectIndexHandler {
constructor(public radius: number, public callback: ActionCallback) {
super();
}
onRender(display: Display) {
const startX = window.engine.mousePosition[0] - this.radius - 1;
const startY = window.engine.mousePosition[1] - this.radius - 1;
for (let x = startX; x < startX + this.radius ** 2; x++) {
for (let y = startY; y < startY + this.radius ** 2; y++) {
const data = display._data[`${x},${y}`];
const char = data ? data[2] || ' ' : ' ';
display.drawOver(x, y, char[0], '#fff', '#f00');
}
}
}
onIndexSelected(x: number, y: number): Action | null {
this.nextHandler = new GameInputHandler();
return this.callback(x, y);
}
}
This handler is similar to the SingleRangedAttackHandler
with one difference being that it also takes a radius
to
keep track of. The other difference is the onRender
method. Here we calculate the start position of target area using
the mouse position and radius of the area. We then loop over each tile in the area, and redraw that tile with a white
foreground and a red background.
Next we'll update consumable.ts
by adding our new handler to the imports at the top of the file. Then add a new
consumable class at the end of the file:
export class FireballDamageConsumable extends Consumable {
constructor(
public damage: number,
public radius: number,
parent: Item | null = null,
) {
super(parent);
}
}
Our new FirebasllDamageConsumable
takes in the damage to deal and the radius it will deal damage in. Like our confusion
scroll, the getAction
method needs to be implemented:
getAction(): Action | null {
window.engine.messageLog.addMessage(
'Select a target location.',
Colors.NeedsTarget,
);
window.engine.inputHandler = new AreaRangedAttackHandler(
this.radius,
(x, y) => {
return new ItemAction(this.parent, [x, y]);
},
);
return null;
}
This code is the same as the confusion scroll's method, with the exception being the handler we use. Now we can implement
the activate
method:
activate(action: ItemAction, _entity: Entity) {
const { targetPosition } = action;
if (!targetPosition) {
throw new ImpossibleException('You must select an area to target.');
}
const [x, y] = targetPosition;
if (!window.engine.gameMap.tiles[y][x].visible) {
throw new ImpossibleException(
'You cannot target an area that you cannot see.',
);
}
let targetsHit = false;
for (let actor of window.engine.gameMap.actors) {
if (actor.distance(x, y) <= this.radius) {
window.engine.messageLog.addMessage(
`The ${actor.name} is engulfed in a fiery explosion, taking ${this.damage} damage!`,
);
actor.fighter.takeDamage(this.damage);
targetsHit = true;
}
if (!targetsHit) {
throw new ImpossibleException('There are no targets in the radius.');
}
this.consume();
}
}
We first destructure the targetPosition
off of the action that was passed in. If we don't have a valid target position,
we throw an exception saying so. If we do have a valid position, we loop over all the actors in the map to see if they
are inside the radius of the fireball. If they are, we add a message to the log saying they were hit and for how much
damage. Then we apply damage to the actor and update our boolean to indicate we hit at least one target. If no targets
were hit by the end of the loop we throw an exception and make sure we don't consume the scroll. If we did hit at least
one target, we consume the scroll and remove it from the inventory.
Next we'll add a spawn function to entity.ts
for our new scroll. Don't forget to import the FireballDamageConsumable
at the top of the file first:
export function spawnFireballScroll(gameMap: GameMap, x: number, y: number) {
return new Item(
x,
y,
'~',
'#ff0000',
'#000',
'Fireball Scroll',
new FireballDamageConsumable(12, 3),
gameMap,
);
}
Our new scroll will deal 12 damage to any actors in a three tile radius of the target position. Now let's add some fireball
scrolls to the map. Open up procgen.ts
and update placeEntities
again, remembering to import our spawn function:
if (!dungeon.entities.some((e) => e.x == x && e.y == y)) {
const itemChance = Math.random();
if (itemChance < 0.7) {
spawnHealthPotion(dungeon, x, y);
} else if (itemChance < 0.8) {
spawnFireballScroll(dungeon, x, y);
} else if (itemChance < 0.9) {
spawnConfusionScroll(dungeon, x, y);
} else {
spawnLightningScroll(dungeon, x, y);
}
}
The last thing we need to do is update the render
method in engine.ts
to use our new onRender
method on our handler.
Put this line at the very end of the render
method:
this.inputHandler.onRender(this.display);
Run the game now and find a fireball scroll. When you use it you should see a large red square that you can move around. This is the area that the scroll will cause damage in. Be careful to not kill yourself with it!
You can find the complete code for this chapter here.