In this chapter we'll add the functionality to save and load our game. Before we can add that important functionality, we need to do some more refactoring. I've been writing these chapters as I work through the Python version of the libtcod tutorial. I do the chapter from the Python tutorial, then implement that same chapter myself in TypeScript. Once that implementation is finished for the chapter, I write a chapter here on this blog. Because I haven't gone through the complete Python tutorial, I've made some assumptions in early parts of the TypeScript implementation that aren't fitting as well as I get further into the chapters.
Rather than rework the tutorials, however, I think this is a good opportunity to highlight that software engineering rarely goes according to plan and that refactoring is something to be embraced. It's a chance to think about your design and improve upon what you've already built. So with that said, we'll be spending the first three sections of this chapter refactoring and making the codebase better going forward. The last two sections will be about adding a menu screen to our game and then saving and loading.
Right now, our Engine
class is responsible for exposing an instance variable for the message log so that other places
throughout the game can add messages. Rather than keeping that as part of the engine, let's just make the message log
global as well. We'll start by adding a message log to the Window
interface in main.ts
:
import { MessageLog } from './message-log';
import { Colors } from './colors';
declare global {
interface Window {
engine: Engine;
messageLog: MessageLog;
}
}
Then we can create a message log instance at the same time we create the engine:
window.addEventListener('DOMContentLoaded', () => {
window.messageLog = new MessageLog();
window.engine = new Engine(spawnPlayer(Engine.WIDTH / 2, Engine.HEIGHT / 2));
window.messageLog.addMessage(
'Hello and welcome, adventurer, to yet another dungeon!',
Colors.WelcomeText,
);
window.engine.render();
});
Now that we have a global message log, let's update all the places in our code that we call addMessage
to use that
new global log. I won't explicitly show every change here, but search through the whole project and replace any calls
to window.engine.messageLog.addMessage
with window.messageLog.addMessage
. You should have changes in actions.ts
,
ai.ts
, consumable.ts
, fighter.ts
, inventory.ts
, and input-handler.ts
. In input-handler.ts
you'll
also need to update LogAction
to reference the global message log length.
While we're in input-handler.ts
there's a small change we make to the AreaRangedAttackHandler
. Thanks to reddit
user JasonSantilli
for the insight that we don't need to access the internal ROT.js data when drawing our targeting box. Update the onRender
method to look like the below:
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++) {
display.drawOver(x, y, null, '#fff', '#f00');
}
}
}
For the last part of this refactor we'll jump over to engine.ts
. We can delete the MessageLog
import at the top,
and remove the instantiation and message adding in the constructor of the Engine
class. Also make sure to update
any references to this.messageLog
to be window.messageLog
. The last thing we can do here is similar to the change
we made to the AreaRangedAttackHandler
. In the render
method, when we check if the input handler is in TARGET
state or not, we can draw without accessing the internal ROT.js data:
if (this.inputHandler.inputState === InputState.Target) {
const [x, y] = this.mousePosition;
this.display.drawOver(x, y, null, '#000', '#fff');
}
Run the game and it should run just as before.
Something else that the engine is currently responsible for tracking is mouse and log position. It makes a lot more sense
to contain that information in the input handlers that would be dealing with mouse input. Open up input-handler.ts
and we'll start by adding some instance variables to the BaseInputHandler
abstract class:
export abstract class BaseInputHandler {
nextHandler: BaseInputHandler;
mousePosition: [number, number];
logCursorPosition: number;
protected constructor(public inputState: InputState = InputState.Game) {
this.nextHandler = this;
this.mousePosition = [0, 0];
this.logCursorPosition = window.messageLog.messages.length - 1;
}
We'll track the mouse and log position in the base handler so that the engine doesn't need to know about a specific handler type, as well as if we wanted to add mouse functionality to other handlers in the future. We'll also add a new method to the base class:
handleMouseMovement(position: [number, number]) {
this.mousePosition = position;
}
This method will get called by the engine to update the position when the mousemove
event fires. Next we'll update
the LogInputHandler
to use this.logCursorPosition
instead of window.engine.logCursorPosition
:
handleKeyboardInput(event: KeyboardEvent): Action | null {
if (event.key === 'Home') {
return new LogAction(() => (this.logCursorPosition = 0));
}
if (event.key === 'End') {
return new LogAction(
() => (this.logCursorPosition = window.messageLog.messages.length - 1),
);
}
const scrollAmount = LOG_KEYS[event.key];
if (!scrollAmount) {
this.nextHandler = new GameInputHandler();
}
return new LogAction(() => {
if (scrollAmount < 0 && this.logCursorPosition === 0) {
this.logCursorPosition = window.messageLog.messages.length - 1;
} else if (
scrollAmount > 0 &&
this.logCursorPosition === window.messageLog.messages.length - 1
) {
this.logCursorPosition = 0;
} else {
this.logCursorPosition = Math.max(
0,
Math.min(
this.logCursorPosition + scrollAmount,
window.messageLog.messages.length - 1,
),
);
}
});
}
Then we need to update the SelectIndexHandler
class to use this.mousePosition
:
export abstract class SelectIndexHandler extends BaseInputHandler {
protected constructor() {
super(InputState.Target);
const { x, y } = window.engine.player;
this.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] = this.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));
this.mousePosition = [x, y];
return null;
} else if (event.key === 'Enter') {
let [x, y] = this.mousePosition;
return this.onIndexSelected(x, y);
}
this.nextHandler = new GameInputHandler();
return null;
}
Then we'll update the onRender
method of AreaRangedAttackHandler
to use the new mouse position as well:
onRender(display: Display) {
const startX = this.mousePosition[0] - this.radius - 1;
const startY = this.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++) {
display.drawOver(x, y, null, '#fff', '#f00');
}
}
}
Next we'll open up render-functions.ts
and make a change to renderNamesAtLocation
:
export function renderNamesAtLocation(
x: number,
y: number,
mousePosition: [number, number],
) {
const [mouseX, mouseY] = mousePosition;
if (
window.engine.gameMap.isInBounds(mouseX, mouseY) &&
window.engine.gameMap.tiles[mouseY][mouseX].visible
) {
const names = window.engine.gameMap.entities
.filter((e) => e.x === mouseX && e.y === mouseY)
.map((e) => e.name.charAt(0).toUpperCase() + e.name.substring(1))
.join(', ');
window.engine.display.drawText(x, y, names);
}
}
We're now getting the mouse position passed in as a parameter instead of grabbing it from the global engine. Now we can
update engine.ts
to reflect all these changes. Start by removing the mousePosition
and logCursorPosition
instance
variables and their assignment in the constructor. Also update any references to this.mousePosition
and this.logCursorPosition
to be this.inputHandler.mousePosition
and this.inputHandler.logCursorPosition
.
We need to update our event handler for the mousemove
event to tell our input handler to update:
window.addEventListener('mousemove', (event) => {
this.inputHandler.handleMouseMovement(
this.display.eventToPosition(event),
);
this.render();
});
We simply take the translated mouse position and pass it to our input handler. Lastly, update the call to renderNamesAtLocation
to pass in the mouse position:
renderNamesAtLocation(21, 44, this.inputHandler.mousePosition);
The application should still run as it did before.
There's one more bit of refactoring we need to do. Right now our engine creates and holds on to the game map. Soon we'll
be introducing a menu screen to allow choosing between starting a new game or loading a saved game. Because of this, it
makes sense to encapsulate the game logic in its own class instead of the engine. Create a new directory called screens
and
a new file called base-screen.ts
and putting these contents in it:
import { Display } from 'rot-js';
import { Actor } from '../entity';
import { BaseInputHandler } from '../input-handler';
export abstract class BaseScreen {
abstract inputHandler: BaseInputHandler;
protected constructor(public display: Display, public player: Actor) {}
abstract update(event: KeyboardEvent): void;
abstract render(): void;
}
This class will be the base for any screens we want to have in our game. Subclasses of this will be responsible for supplying
update and render implementations. Now we can create our GameScreen
that will inherit from this base class. I'll post the
full contents of the file here, but I'm not going to break down every line. This is basically lifting most of the code
from engine.ts
and slightly changing it to work in this new implementation.
import { BaseScreen } from './base-screen';
import { GameMap } from '../game-map';
import { Display } from 'rot-js';
import { generateDungeon } from '../procgen';
import { Actor } from '../entity';
import {
BaseInputHandler,
GameInputHandler,
InputState,
} from '../input-handler';
import { Action } from '../actions';
import { ImpossibleException } from '../exceptions';
import { Colors } from '../colors';
import {
renderFrameWithTitle,
renderHealthBar,
renderNamesAtLocation,
} from '../render-functions';
export class GameScreen extends BaseScreen {
public static readonly MAP_WIDTH = 80;
public static readonly MAP_HEIGHT = 43;
public static readonly MIN_ROOM_SIZE = 6;
public static readonly MAX_ROOM_SIZE = 10;
public static readonly MAX_ROOMS = 30;
public static readonly MAX_MONSTERS_PER_ROOM = 2;
public static readonly MAX_ITEMS_PER_ROOM = 2;
gameMap: GameMap;
inputHandler: BaseInputHandler;
constructor(display: Display, player: Actor) {
super(display, player);
this.gameMap = generateDungeon(
GameScreen.MAP_WIDTH,
GameScreen.MAP_HEIGHT,
GameScreen.MAX_ROOMS,
GameScreen.MIN_ROOM_SIZE,
GameScreen.MAX_ROOM_SIZE,
GameScreen.MAX_MONSTERS_PER_ROOM,
GameScreen.MAX_ITEMS_PER_ROOM,
this.player,
this.display,
);
this.inputHandler = new GameInputHandler();
this.gameMap.updateFov(this.player);
}
handleEnemyTurns() {
this.gameMap.actors.forEach((e) => {
if (e.isAlive) {
try {
e.ai?.perform(e, this.gameMap);
} catch {}
}
});
}
update(event: KeyboardEvent) {
const action = this.inputHandler.handleKeyboardInput(event);
if (action instanceof Action) {
try {
action.perform(this.player, this.gameMap);
this.handleEnemyTurns();
this.gameMap.updateFov(this.player);
} catch (error) {
if (error instanceof ImpossibleException) {
window.messageLog.addMessage(error.message, Colors.Impossible);
}
}
}
this.inputHandler = this.inputHandler.nextHandler;
this.render();
}
render() {
this.display.clear();
window.messageLog.render(this.display, 21, 45, 40, 5);
renderHealthBar(
this.display,
this.player.fighter.hp,
this.player.fighter.maxHp,
20,
);
renderNamesAtLocation(21, 44, this.inputHandler.mousePosition);
this.gameMap.render();
if (this.inputHandler.inputState === InputState.Log) {
renderFrameWithTitle(3, 3, 74, 38, 'Message History');
window.messageLog.renderMessages(
this.display,
4,
4,
72,
36,
window.messageLog.messages.slice(
0,
this.inputHandler.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');
}
if (this.inputHandler.inputState === InputState.Target) {
const [x, y] = this.inputHandler.mousePosition;
this.display.drawOver(x, y, null, '#000', '#fff');
}
this.inputHandler.onRender(this.display);
}
renderInventory(title: string) {
const itemCount = this.player.inventory.items.length;
const height = itemCount + 2 <= 3 ? 3 : itemCount + 2;
const width = title.length + 4;
const x = this.player.x <= 30 ? 40 : 0;
const y = 0;
renderFrameWithTitle(x, y, width, height, title);
if (itemCount > 0) {
this.player.inventory.items.forEach((i, index) => {
const key = String.fromCharCode('a'.charCodeAt(0) + index);
this.display.drawText(x + 1, y + index + 1, `(${key}) ${i.name}`);
});
} else {
this.display.drawText(x + 1, y + 1, '(Empty)');
}
}
}
Now that our map is contained in this new GameScreen
we'll make some changes throughout our codebase so that we aren't
referencing the old map on the engine. Let's start in render-functions.ts
and update the renderNamesAtLocation
function:
export function renderNamesAtLocation(
x: number,
y: number,
mousePosition: [number, number],
gameMap: GameMap
) {
const [mouseX, mouseY] = mousePosition;
if (
gameMap.isInBounds(mouseX, mouseY) &&
gameMap.tiles[mouseY][mouseX].visible
) {
const names = gameMap.entities
.filter((e) => e.x === mouseX && e.y === mouseY)
.map((e) => e.name.charAt(0).toUpperCase() + e.name.substring(1))
.join(', ');
window.engine.display.drawText(x, y, names);
}
}
We'll be passing in our game map when needed now instead of looking for a global one on window.engine
. We'll make a
similar change in inventory.ts
:
import { BaseComponent } from './base-component';
import { Actor, Item } from '../entity';
import { GameMap } from '../game-map';
export class Inventory extends BaseComponent {
parent: Actor | null;
items: Item[];
constructor(public capacity: number) {
super();
this.parent = null;
this.items = [];
}
drop(item: Item, gameMap: GameMap) {
const index = this.items.indexOf(item);
if (index >= 0) {
this.items.splice(index, 1);
if (this.parent) {
item.place(this.parent.x, this.parent.y, gameMap);
}
window.messageLog.addMessage(`You dropped the ${item.name}."`);
}
}
}
Now the drop
method takes in a game map instead of using the global one. Now let's jump over to consumable.ts
to
make a few changes. First we'll import GameMap
and update the abstract activate
signature:
import { GameMap } from '../game-map';
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(action: ItemAction, entity: Entity, gameMap: GameMap): void;
Then we'll update the LightningConsumable
to utilize this passed in game map:
activate(_action: ItemAction, entity: Entity, gameMap: GameMap) {
let target: Actor | null = null;
let closestDistance = this.maxRange + 1.0;
for (const actor of gameMap.actors) {
if (
!Object.is(actor, entity) &&
gameMap.tiles[actor.y][actor.x].visible
) {
const distance = entity.distance(actor.x, actor.y);
if (distance < closestDistance) {
target = actor;
closestDistance = distance;
}
}
}
// rest of method omitted for brevity
Next we can update the ConfusionConsumabe
class's getAction
method to set the input handler for the current screen:
getAction(): Action | null {
window.messageLog.addMessage(
'Select a target location.',
Colors.NeedsTarget,
);
window.engine.screen.inputHandler = new SingleRangedAttackHandler(
(x, y) => {
return new ItemAction(this.parent, [x, y]);
},
);
return null;
}
We haven't added the screen
property to the engine yet, but we will shortly. Still in ConfusionConsumable
we can update
the activate
method to use the passed in game map:
activate(action: ItemAction, entity: Entity, gameMap: GameMap) {
const target = action.targetActor(gameMap);
if (!target) {
throw new ImpossibleException('You must select an enemy to target.');
}
if (!gameMap.tiles[target.y][target.x].visible) {
throw new ImpossibleException(
'You cannot target an area you cannot see.',
);
// rest of method omitted for brevity
Next we'll update the getAction
method for FireballDamageConsumable
much like we did for ConfusionConsumable
:
getAction(): Action | null {
window.messageLog.addMessage(
'Select a target location.',
Colors.NeedsTarget,
);
window.engine.screen.inputHandler = new AreaRangedAttackHandler(
this.radius,
(x, y) => {
return new ItemAction(this.parent, [x, y]);
},
);
return null;
}
And we'll also update the activate
method:
activate(action: ItemAction, _entity: Entity, gameMap: GameMap) {
const { targetPosition } = action;
if (!targetPosition) {
throw new ImpossibleException('You must select an area to target.');
}
const [x, y] = targetPosition;
if (!gameMap.tiles[y][x].visible) {
throw new ImpossibleException(
'You cannot target an area that you cannot see.',
);
}
let targetsHit = false;
for (let actor of gameMap.actors) {
if (actor.distance(x, y) <= this.radius) {
window.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();
}
}
Next we can make changes to our ai implementations in ai.ts
. First import GameMap
and then we can update the perform
signature and the implementation of calculatePathTo
to use a passed in game map instead of the global:
abstract perform(entity: Entity, gameMap: GameMap): void;
calculatePathTo(
destX: number,
destY: number,
entity: Entity,
gameMap: GameMap,
) {
const isPassable = (x: number, y: number) => gameMap.tiles[y][x].walkable;
// rest of method unchanged
We then need to update the perform
implementation of HostileEnemy
:
perform(entity: Entity, gameMap: GameMap) {
const target = window.engine.player;
const dx = target.x - entity.x;
const dy = target.y - entity.y;
const distance = Math.max(Math.abs(dx), Math.abs(dy));
if (gameMap.tiles[entity.y][entity.x].visible) {
if (distance <= 1) {
return new MeleeAction(dx, dy).perform(entity as Actor, gameMap);
}
this.calculatePathTo(target.x, target.y, entity, gameMap);
}
if (this.path.length > 0) {
const [destX, destY] = this.path[0];
this.path.shift();
return new MovementAction(destX - entity.x, destY - entity.y).perform(
entity,
gameMap,
);
}
return new WaitAction().perform(entity);
}
We also need to make a similar change to ConfusedEnemy
:
perform(entity: Entity, gameMap: GameMap) {
const actor = entity as Actor;
if (!actor) return;
if (this.turnsRemaining <= 0) {
window.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, gameMap);
}
}
Next we'll jump over to actions.ts
and start by importing GameMap
. Then we can update the abstract perform
signature on the Action
class:
export abstract class Action {
abstract perform(entity: Entity, gameMap: GameMap): void;
}
Now we need to update all the various actions to use this passed in game map, staring with PickupAction
:
export class PickupAction extends Action {
perform(entity: Entity, gameMap: GameMap) {
const consumer = entity as Actor;
if (!consumer) return;
const { x, y, inventory } = consumer;
for (const item of gameMap.items) {
if (x === item.x && y == item.y) {
if (inventory.items.length >= inventory.capacity) {
throw new ImpossibleException('Your inventory is full.');
}
window.engine.screen.gameMap?.removeEntity(item);
item.parent = inventory;
inventory.items.push(item);
window.messageLog.addMessage(`You picked up the ${item.name}!`);
return;
}
}
throw new ImpossibleException('There is nothing here to pick up.');
}
}
Next will be ItemAction
where we will also update targetActor
to be a method instead of a getter so we can pass
in the game map:
export class ItemAction extends Action {
constructor(
public item: Item | null,
public targetPosition: [number, number] | null = null,
) {
super();
}
targetActor(gameMap: GameMap): Actor | undefined {
if (!this.targetPosition) {
return;
}
const [x, y] = this.targetPosition;
return gameMap.getActorAtLocation(x, y);
}
perform(entity: Entity, gameMap: GameMap) {
this.item?.consumable.activate(this, entity, gameMap);
}
}
Then we'll update our ActionWithDirection
, MovementAction
, BumpAction
and MeleeAction
classes:
export abstract class ActionWithDirection extends Action {
constructor(public dx: number, public dy: number) {
super();
}
abstract perform(entity: Entity, gameMap: GameMap): void;
}
export class MovementAction extends ActionWithDirection {
perform(entity: Entity, gameMap: GameMap) {
const destX = entity.x + this.dx;
const destY = entity.y + this.dy;
if (!gameMap.isInBounds(destX, destY)) {
throw new ImpossibleException('That way is blocked.');
}
if (!gameMap.tiles[destY][destX].walkable) {
throw new ImpossibleException('That way is blocked.');
}
if (gameMap.getBlockingEntityAtLocation(destX, destY)) {
throw new ImpossibleException('That way is blocked.');
}
entity.move(this.dx, this.dy);
}
}
export class BumpAction extends ActionWithDirection {
perform(entity: Entity, gameMap: GameMap) {
const destX = entity.x + this.dx;
const destY = entity.y + this.dy;
if (gameMap.getActorAtLocation(destX, destY)) {
return new MeleeAction(this.dx, this.dy).perform(
entity as Actor,
gameMap,
);
} else {
return new MovementAction(this.dx, this.dy).perform(entity, gameMap);
}
}
}
export class MeleeAction extends ActionWithDirection {
perform(actor: Actor, gameMap: GameMap) {
const destX = actor.x + this.dx;
const destY = actor.y + this.dy;
const target = gameMap.getActorAtLocation(destX, destY);
if (!target) {
throw new ImpossibleException('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.messageLog.addMessage(
`${attackDescription} for ${damage} hit points.`,
fg,
);
target.fighter.hp -= damage;
} else {
window.messageLog.addMessage(
`${attackDescription} but does no damage.`,
fg,
);
}
}
}
The last action we need to update is DropItem
:
export class DropItem extends ItemAction {
perform(entity: Entity, gameMap: GameMap) {
const dropper = entity as Actor;
if (!dropper || !this.item) return;
dropper.inventory.drop(this.item, gameMap);
}
}
Next we need to update engine.ts
to utilize our new GameScreen
class. I'll put the full contents of the update file
below:
import * as ROT from 'rot-js';
import { BaseInputHandler, GameInputHandler } from './input-handler';
import { Actor, spawnPlayer } from './entity';
import { BaseScreen } from './screens/base-screen';
import { GameScreen } from './screens/game-screen';
export class Engine {
public static readonly WIDTH = 80;
public static readonly HEIGHT = 50;
public static readonly MAP_WIDTH = 80;
public static readonly MAP_HEIGHT = 43;
display: ROT.Display;
inputHandler: BaseInputHandler;
screen: BaseScreen;
player: Actor;
constructor() {
this.display = new ROT.Display({
width: Engine.WIDTH,
height: Engine.HEIGHT,
forceSquareRatio: true,
});
this.player = spawnPlayer(
Math.floor(Engine.MAP_WIDTH / 2),
Math.floor(Engine.MAP_HEIGHT / 2),
);
const container = this.display.getContainer()!;
document.body.appendChild(container);
this.inputHandler = new GameInputHandler();
window.addEventListener('keydown', (event) => {
this.update(event);
});
window.addEventListener('mousemove', (event) => {
this.inputHandler.handleMouseMovement(
this.display.eventToPosition(event),
);
this.screen.render();
});
this.screen = new GameScreen(this.display, this.player);
}
update(event: KeyboardEvent) {
const screen = this.screen.update(event);
}
}
As you can see, we have greatly simplified our Engine
class. Now it is responsible for creating the necessary things
for starting up our game, and then delegates control to the GameScreen
. The last thing we need to do for all our
refactors is update main.ts
when we instantiate our engine:
import { Engine } from './engine';
import { MessageLog } from './message-log';
import { Colors } from './colors';
declare global {
interface Window {
engine: Engine;
messageLog: MessageLog;
}
}
window.addEventListener('DOMContentLoaded', () => {
window.messageLog = new MessageLog();
window.engine = new Engine();
window.messageLog.addMessage(
'Hello and welcome, adventurer, to yet another dungeon!',
Colors.WelcomeText,
);
window.engine.screen.render();
});
We no longer need to spawn a player to pass to the engine as the engine will handle that itself. Run that game and it should still function as before.
Now we can finally start adding some new functionality to our game! We'll start by making a small change in base-screen.ts
:
abstract update(event: KeyboardEvent): BaseScreen;
Our update method will now return a BaseScreen
instance. We'll use this to transition between screens. We now need to
update our GameScreen
class to utilize this:
update(event: KeyboardEvent): BaseScreen {
const action = this.inputHandler.handleKeyboardInput(event);
if (action instanceof Action) {
try {
action.perform(this.player, this.gameMap);
this.handleEnemyTurns();
this.gameMap?.updateFov(this.player);
} catch (error) {
if (error instanceof ImpossibleException) {
window.messageLog.addMessage(error.message, Colors.Impossible);
}
}
}
this.inputHandler = this.inputHandler.nextHandler;
this.render();
return this;
}
For now, we won't be transitioning away from the game screen once we're there, so all we have to do is return this
at
the end of the update method. Now we can add a main menu screen to the game. Add a new file to our screens
directory
called main-menu.ts
. We'll start by adding our imports and some constants:
import { Display } from 'rot-js';
import { BaseScreen } from './base-screen';
import { Actor } from '../entity';
import { Engine } from '../engine';
import { BaseInputHandler, GameInputHandler } from '../input-handler';
import { GameScreen } from './game-screen';
const OPTIONS = [
'[N] Play a new game',
'[C] Continue last game', // TODO: hide this option if no save game is present
];
const MENU_WIDTH = 24;
The OPTIONS
constant represents the menu options we'll draw to the screen. Once we get save/load functionality implemented
we'll hide the second option if no save game is present. Let's add our new screen class:
export class MainMenu extends BaseScreen {
inputHandler: BaseInputHandler;
constructor(display: Display, player: Actor) {
super(display, player);
this.inputHandler = new GameInputHandler();
}
We set up an input handler in the constructor because BaseScreen
parent class requires one, but we won't be using it
in our main menu. Next we'll add an update
method:
update(event: KeyboardEvent): BaseScreen {
if (event.key === 'n') {
return new GameScreen(this.display, this.player);
}
this.render();
return this;
}
IF the player presses 'n' we'll return a GameScreen
instance while will start up a whole new game for us. Otherwise
we'll just render and stay on the menu screen. Now we just need to implement the render
method:
render() {
this.display.clear();
OPTIONS.forEach((o, i) => {
const x = Math.floor(Engine.WIDTH / 2);
const y = Math.floor(Engine.HEIGHT / 2 - 1 + i);
this.display.draw(x, y, o.padEnd(MENU_WIDTH, ' '), '#fff', '#000');
});
}
We loop over all the options for the menu and draw them to the screen. The last thing we need to do to get our menu
working is update engine.ts
. First change the import from GameScreen
to MainMenu
since the menu will deal with
loading the game for us. Then we'll update when we instantiate the screen in the Engine
constructor:
this.screen = new MainMenu(this.display, this.player);
Finally, we need to change the update
method to use our newly returned screen:
update(event: KeyboardEvent) {
const screen = this.screen.update(event);
if (!Object.is(screen, this.screen)) {
this.screen = screen;
this.screen.render();
}
}
Every time we call update
on a screen, it will return a BaseScreen
instance. If a new screen isn't loaded, it will return
itself, so we check if it's the same screen instance. If it isn't the same, we update and re-render. If you run the game
now it should start with our menu displayed. Pressing 'n' on your keyboard should start a new game.
We have a menu, now we just need to be able to save a game, so we can load it from the menu later. We'll save our games
by writing the state of the current game screen to local storage in the browser. In order to do this, we need to serialize
an instance of the GameScreen
class to a string. We can't serialize the entire GameScreen
class as we can't have
string representations of methods or constructors. Let's start by introducing some new types that will represent precisely
what we want to have in our game save:
type SerializedGameMap = {
width: number;
height: number;
tiles: Tile[][];
entities: SerializedEntity[];
};
The SerializedGameMap
is the base level type that we'll save to local storage. It holds the width and height of the
map, all the tiles for the current map, and a list of entities in the map. Those entities will each be serialized with
their own type as well:
type SerializedEntity = {
x: number;
y: number;
char: string;
fg: string;
bg: string;
name: string;
fighter: SerializedFighter | null;
aiType: string | null;
confusedTurnsRemaining: number;
inventory: SerializedItem[] | null;
};
type SerializedFighter = {
maxHp: number;
hp: number;
defense: number;
power: number;
};
type SerializedItem = {
itemType: string;
};
A SerializedEntity
contains all the data for a given entity. Some properties are nullable since not all entities are
fighters/ai enemies/items. We represent the current ai type as a string. A fighter is serialized to contain all the
current stats. Items just contain the item type.
This is a very basic style of serialization and it isn't very flexible. If you were to expand this into a larger game, it would be a good idea to delegate serialization to each of the entity classes and their child classes. The way we are doing it here will serve our purposes, but isn't ideal.
We can now use these new types to write a function that will create an object representation of an instance of a game
screen. Let's add a new toObject
method to the GameScreen
class:
private toObject(): SerializedGameMap {
return {
width: this.gameMap.width,
height: this.gameMap.height,
tiles: this.gameMap.tiles,
entities: this.gameMap.entities.map((e) => {
let fighter = null;
let aiType = null;
let inventory = null;
let confusedTurnsRemaining = 0;
if (e instanceof Actor) {
const actor = e as Actor;
const { maxHp, _hp: hp, defense, power } = actor.fighter;
fighter = { maxHp, hp, defense, power };
if (actor.ai) {
aiType = actor.ai instanceof HostileEnemy ? 'hostile' : 'confused';
confusedTurnsRemaining =
aiType === 'confused'
? (actor.ai as ConfusedEnemy).turnsRemaining
: 0;
}
if (actor.inventory) {
inventory = [];
for (let item of actor.inventory.items) {
inventory.push({ itemType: item.name });
}
}
}
return {
x: e.x,
y: e.y,
char: e.char,
fg: e.fg,
bg: e.bg,
name: e.name,
fighter,
aiType,
confusedTurnsRemaining,
inventory,
};
}),
};
}
The tricky part in this method is where we map over each of the entities in the game map. Here we check if the entity is an actor or not. If it is an actor we serialize the fighter, AI, and inventory information. Otherwise, we just serialize the basic entity info. Now let's write a method that will use this to actually save the information to local storage:
private saveGame() {
try {
localStorage.setItem('roguesave', JSON.stringify(this.toObject()));
} catch (err) {}
}
We simply call the new toObject
method, stringify the object representation of the game screen, and then write that
string into local storage using the key roguesave
. Now let's add a small if
statement to the top of the update
method in GameScreen
to add a key to save the current state of a game:
update(event: KeyboardEvent): BaseScreen {
if (event.key === 's') {
this.saveGame();
return this;
}
If you run the game and press the 's' key, then open up the developer tools in your browser and check your local storage,
you should see an entry for roguesave
that contains all the current state of the game. Now let's build the functionality
to load one of these saves. First we'll open up entity.ts
and fix the return types of some of our spawn functions:
export function spawnOrc(gameMap: GameMap, x: number, y: number): Actor {
export function spawnTroll(gameMap: GameMap, x: number, y: number): Actor {
export function spawnHealthPotion(gameMap: GameMap, x: number, y: number): Item {
export function spawnLightningScroll(gameMap: GameMap, x: number, y: number): Item {
export function spawnConfusionScroll(gameMap: GameMap, x: number, y: number): Item {
export function spawnFireballScroll(gameMap: GameMap, x: number, y: number): Item {
We want these return types to be accurate so that we can tell utilize the return types when loading with casting. Now we'll add
a static method to GameScreen
that will load a game based on a serialized string:
private static load(
serializedGameMap: string,
display: Display,
): [GameMap, Actor] {
Our load
method takes in a string representing a saved game screen and a display to start rendering to and returns a
tuple that will have the loaded map, and the player entity.
const parsedMap = JSON.parse(serializedGameMap) as SerializedGameMap;
const playerEntity = parsedMap.entities.find((e) => e.name === 'Player');
if (!playerEntity) throw new Error('Player not found');
const player = spawnPlayer(playerEntity.x, playerEntity.y);
player.fighter.hp = playerEntity.fighter?.hp || player.fighter.hp;
window.engine.player = player;
We then parse that string into a SerializedGameMap
object. Once we have that object, we first find the player entity
in the list of entities, spawn a new player at that location, and set the hp accordingly.
const map = new GameMap(parsedMap.width, parsedMap.height, display, [
player,
]);
map.tiles = parsedMap.tiles;
We then create a new GameMap
instance using the saved width and height and the newly spawned player. Once we have a
game map we set the tiles of that map equal to the saved tiles.
const playerInventory = playerEntity?.inventory || [];
for (let entry of playerInventory) {
let item: Item | null = null;
switch (entry.itemType) {
case 'Health Potion': {
item = spawnHealthPotion(map, 0, 0);
break;
}
case 'Lightning Scroll': {
item = spawnLightningScroll(map, 0, 0);
break;
}
case 'Confusion Scroll': {
item = spawnConfusionScroll(map, 0, 0);
break;
}
case 'Fireball Scroll': {
item = spawnFireballScroll(map, 0, 0);
break;
}
}
if (item) {
map.removeEntity(item);
item.parent = player.inventory;
player.inventory.items.push(item);
}
}
We then loop over all the items in the player's inventory and spawn new versions of them. We start by spawning them at (0,0) on the map, and then push them into the player's inventory and remove them from the map, since they are in the player's inventory and not on the map anymore.
for (let e of parsedMap.entities) {
if (e.name === 'Orc') {
const orc = spawnOrc(map, e.x, e.y);
orc.fighter.hp = e.fighter?.hp || orc.fighter.hp;
if (e.aiType === 'confused') {
orc.ai = new ConfusedEnemy(orc.ai, e.confusedTurnsRemaining);
}
} else if (e.name === 'Troll') {
const troll = spawnTroll(map, e.x, e.y);
troll.fighter.hp = e.fighter?.hp || troll.fighter.hp;
if (e.aiType === 'confused') {
troll.ai = new ConfusedEnemy(troll.ai, e.confusedTurnsRemaining);
}
} else if (e.name === 'Health Potion') {
spawnHealthPotion(map, e.x, e.y);
} else if (e.name === 'Lightning Scroll') {
spawnLightningScroll(map, e.x, e.y);
} else if (e.name === 'Confusion Scroll') {
spawnConfusionScroll(map, e.x, e.y);
} else if (e.name === 'Fireball Scroll') {
spawnFireballScroll(map, e.x, e.y);
}
}
return [map, player];
}
Finally, we loop over all the entities in the save game and apwn new versions of them, setting their attributes accordingly.
For Actor
types, we set their hp, and set their AI type. This way if we save the game while an enemy is confused, they
will still be confused when we load the game again. We then return the new map and the player entity. We'll make use of
this new load
method in the GameScreen
constructor:
constructor(
display: Display,
player: Actor,
serializedGameMap: string | null = null,
) {
super(display, player);
if (serializedGameMap) {
const [map, loadedPlayer] = GameScreen.load(serializedGameMap, display);
this.gameMap = map;
this.player = loadedPlayer;
} else {
this.gameMap = generateDungeon(
GameScreen.MAP_WIDTH,
GameScreen.MAP_HEIGHT,
GameScreen.MAX_ROOMS,
GameScreen.MIN_ROOM_SIZE,
GameScreen.MAX_ROOM_SIZE,
GameScreen.MAX_MONSTERS_PER_ROOM,
GameScreen.MAX_ITEMS_PER_ROOM,
this.player,
this.display,
);
}
this.inputHandler = new GameInputHandler();
this.gameMap.updateFov(this.player);
}
We're adding a new optional parameter to our constructor that will be used to pass in a save game string when loading
a game. If we're starting a whole new game, this parameter will be null. If a save game string is passed in, we then
call our load
method to bring the old game back up. Let's bring this all together by opening up main-menu.ts
to make
some changes:
import { renderFrameWithTitle } from '../render-functions';
const OPTIONS = ['[N] Play a new game'];
if (localStorage.getItem('roguesave')) {
OPTIONS.push('[C] Continue last game');
}
We'll use the renderFrameWithTitle
function to render a popup message in the event a saved game fails to load. We
also change our OPTIONS
to only include the continue option if we have a game saved already. We'll add a new
instance variable to the MainMenu
class:
export class MainMenu extends BaseScreen {
inputHandler: BaseInputHandler;
showPopup: boolean;
constructor(display: Display, player: Actor) {
super(display, player);
this.inputHandler = new GameInputHandler();
this.showPopup = false;
}
showPopup
will track whether we want to display an error message to the user. Next we'll modify the update
method:
update(event: KeyboardEvent): BaseScreen {
if (this.showPopup) {
this.showPopup = false;
} else {
if (event.key === 'n') {
return new GameScreen(this.display, this.player);
} else if (event.key === 'c') {
try {
const saveGame = localStorage.getItem('roguesave');
return new GameScreen(this.display, this.player, saveGame);
} catch {
this.showPopup = true;
}
}
}
this.render();
return this;
}
We first check if we are currently showing the pop-up message and if we are, dismiss it with any keypress. Otherwise we check if 'n' is pressed and start a new game, or if 'c' is pressed, we attempt to retrieve a save game from local storage and load it. Any errors in loading would cause us to display the pop-up message.
Finally, we'll update the render
method to draw the popup message when we have an error loading a saved game:
render() {
this.display.clear();
OPTIONS.forEach((o, i) => {
const x = Math.floor(Engine.WIDTH / 2);
const y = Math.floor(Engine.HEIGHT / 2 - 1 + i);
this.display.draw(x, y, o.padEnd(MENU_WIDTH, ' '), '#fff', '#000');
});
if (this.showPopup) {
const text = 'Failed to load save.';
const options = this.display.getOptions();
const width = text.length + 4;
const height = 7;
const x = options.width / 2 - Math.floor(width / 2);
const y = options.height / 2 - Math.floor(height / 2);
renderFrameWithTitle(x, y, width, height, 'Error');
this.display.drawText(x + 1, y + 3, text);
}
}
If you run the game now you should be able to save a game at any time by pressing the 's' key. Try reloading the game after that and hitting 'c' at the main menu and it should load your save exactly where you left off. You can find the complete code for this chapter here.