Interactive dialogues in HTML5 games with Twine - 6/11
This is an open-source project on game development with a focus on the HTML5 Excalibur game engine. Check out this feature implementation on this commit in our Github repository!
Welcome to the 6th part of my ExcaliburJS HTML5 game engine tutorial serie! You can read here the list of features we are going to develop for our Excalibur game, such as data persistence between scenes and between game sessions. If you're interested in game development, follow me on Twitter and Instagram to be caught up on my latest news!
Table of Contents
In this tutorial: Twine
In this tutorial we integrate Twine to our game, to manage an interactive dialog between the player and the mentor.
In this commit we've added another character to our scene, the mentor, following the same steps we did to add the main player to the scene. The mentor's scope is to teach and guide the player through the game. In our case, the mentor's purpose is to let the user know what she has to do, that is to look for the sword. We want to make our game as involving and interactive as we can, and it would be nice to let the user talk deciding what to say. We can reach this point by enriching the game with an interactive dialog. To implement an interactive dialog and all of its the logic would be quite an amount of work, and we already have a lot to do on our own. Twine is an open-source tool for telling interactive, nonlinear stories. and its stories can be exported in html format
. It can, therefore, do the work in our place, since we can easily integrate Twine exported dialogs into our game. Let's find out how!
Twine: suggested reading
To follow this tutorial it's not needed for you to know how to write a Twine story since we will provide ours (you can find it named as mission.html
in the public repository of this project). However, if you like the Twine integration, you can enjoy all of the Twine features by reading its documentation and writing your own stories and dialogues to use in your Excalibur game. To write the Twine interactive dialogue you'll see, we've adopted the Harlowe format.
Let's made them talk
As we've said, we are going to use Twine to manage an interactive dialog between the player and the mentor. This is the scene we've imagined: the player approaches the mentor, who changes position to express openness to dialogue. If a certain event triggered by the user occurs (in our case, the user clicks or taps on the mentor), then a modal opens in which the interactive dialogue is executed.
From a technical point of view, we need to do two things:
- Listen for click events on the mentor
- Execute Twine story
Listen for click events on the mentor
This piece of work is easy. By reading Excalibur documentation on mouse and touch events, we know that to make an actor listen for events is as easy as
player.on('pointerup', (ev) => {
player.logger.info('Player selected!', ev)
});
In our case, we want the Mentor character to listen for user inputs: if the user either clicks or touches the mentor, the Twine dialogue has to start. Following the above example, we register pointer events in the initialize
method of the Mentor
class:
export class Mentor extends ExcaliburActor {
...
public onInitialize(_engine: Engine) {
super.onInitialize(_engine);
this.graphics.use(ActorAnimationsKeys.IDLE);
this.on('pointerdown', () => {
// start the Twine interactive dialogue
});
}
}
Last but not least, we would like for the mentor to change position as soon as the player approaches it, to give the idea that it's possible to start a dialog with it. The best way to do it is to check for the player position in the postUpdate
method of the Actor
class:
export class Mentor extends ExcaliburActor {
...
private queryManagerService = Container.get(QueryManagerService);
...
public onInitialize(_engine: Engine) {
...
}
public onPostUpdate(_engine: Engine, _delta: number) {
const player = this.queryManagerService.getPlayer(_engine.currentScene);
const playerIsNear = player.pos.distance(this.pos) < 150;
if (playerIsNear) {
this.graphics.use(ActorAnimationsKeys.HELLO);
} else {
this.graphics.use(ActorAnimationsKeys.IDLE);
}
}
}
Execute Twine story
Technical insight
As written in Twine documentation, its stories can be exported in html
format. This means we can download an *.html
file containing all the html
, css
and javascript
needed to play the interactive story, which for us will be an interactive dialogue. The question is: how to integrate it into our Excalibur game? Let's recap the current situation:
- The Excalibur game is rendered in a
canvas html
element, contained in the body of anindex.html
file - The Twine story works on its own
index.html
file
The two things seem incompatible, right? That's because they are! It's certainly possible to manage to load our javascript
into the Twine HTML since it's possible to write custom javascript in it, but this seems to be a rather complicated solution. There's another one which looks easier: to exploit iframe
elements capabilities.
Technical solution
According to MDN documentation, iframes are
The iframe HTML element represents a nested browsing context, embedding another HTML page into the current one.
This seems exactly what we were looking for: using an iframe
, we will be able to display the Twine story, loaded in a index.html
file (in the our code it's called mission.html), independently of the main index.html
file where our game is being executed.
Here's the events flow and what the technical solution should be able to do:
- The player approaches the mentor
- The mentor changes graphics to express openness to dialogue
- In the
postUpdate()
method we're checking for the player position
- In the
- The user clicks or touches the mentor. The Twine dialogue starts
- A service, let's call it an
IFrameService
, starts the Twine dialog in aniframe
HTML element
- A service, let's call it an
- When the Twine dialogue ends, the modal where it is displayed is removed
-
- The
IFrameService
remove theiframe
HTML element containing the Twine exported dialogue
- The
The user clicks or touches the mentor. The Twine dialogue starts
Let's start with this piece of code. We're already listening for the pointer events on the mentor. What we're missing is to call the service responsible for displaying/removing the iframe
. Let's create it under the src/services
folder:
@Service()
export class IFrameService {
public toggleIFrame(show: boolean):void {
if (show) {
//show iframe
} else {
//remove iframe
}
}
}
As you can see, the toggleIFrame
method is empty for now. We will write it in a minute. First, let's change the Mentor
class to call the service on pointer-events:
export class Mentor extends ExcaliburActor {
...
private iFrameService = Container.get(IFrameService);
public onInitialize(_engine: Engine) {
super.onInitialize(_engine);
this.graphics.use(ActorAnimationsKeys.IDLE);
this.on('pointerdown', () => {
this.iFrameService.toggleIFrame(true);
});
}
...
}
Now that the pieces are on their right places, let's think about the IFrameService
. It is supposed to create and remove the iframe
element as requested. At the same time, the service responsible for managing the html
elements is the DOMService
. This service does not have at the moment methods to create or remove elements, or hide them: let's write them!
@Service()
export class DOMService {
...
public toggleElementVisibility(elementId: DomElementIds, visible:boolean) {
const exist = this.elementExist(elementId);
if (exist) {
const element = this.getElement(elementId);
element.style.visibility = visible ? 'visible' : 'hidden';
}
}
public createHTMLElement({id, tagName, classes, parentContainer, attributes,}: HTMLElementConfig) {
const htmlElement = document.createElement(tagName);
htmlElement.id = id;
if (Util.isDefined(parentContainer)) {
parentContainer.appendChild(htmlElement);
} else {
document.body.appendChild(htmlElement);
}
classes?.forEach((c) => htmlElement.classList.add(c));
attributes?.forEach(({ name: attributeName, value: attributeValue }) => htmlElement.setAttribute(attributeName, attributeValue));
this.map.set(id, htmlElement);
return htmlElement;
}
public removeElement(elementId: DomElementIds): void {
const element = this.getElement(elementId);
element?.remove();
}
}
...
export type HTMLElementConfig = {tagName:string, id:DomElementIds, classes?:string[], parentContainer?:HTMLElement, attributes?:HTMLElementAttribute[]}
export type HTMLElementAttribute = {name:string, value:string};
- The
toggleElementVisibility
method changes thevisibility
on anhtml
element, if it exists - The
createHTMLElement
methods create anhtml
element according to the given configuration, and append it to the specified parent element or to thedocument.body
. In the configuration's possible to specify the element id, the tagName, thecss
classes to apply, thehtml
attributes to set, and the parent container - The
removeElement
method removes an element if it exists
The DOMService is now exposing the methods we need to call in the IFrameService
to create and remove the iframe
element, and to hide or show the elements. Let's get back to it then, and write the empty methods:
@Service()
export class IFrameService {
private domService: DOMService = Container.get(DOMService);
private iFrameContainerElement: HTMLElement;
public init(): void {
this.iFrameContainerElement = this.domService.getElement('iframe-container');
}
public toggleIFrame(show: boolean):void {
this.domService.toggleElementVisibility('iframe-container', show);
this.domService.toggleElementVisibility('joystick', !show);
if (show) {
const config: HTMLElementConfig = {
id: 'iframe',
tagName: 'iframe',
classes: ['position-absolute', 'position-top', 'full-size'],
parentContainer: this.iFrameContainerElement,
attributes: [{ name: 'src', value: 'twinery-dialog/mission.html' }],
};
this.domService.createHTMLElement(config);
} else {
//remove iframe
}
}
}
The iframe-container
element is the empty div
that will host the iframe
: we need to add it to the index.html
file:
<body>
...
<canvas id="game" style="display: block; margin: auto auto"></canvas>
<div class="position-absolute position-top full-size" id="iframe-container" style="visibility: hidden;"></div>
...
</body>
The IFrameService
takes advantage of this element to append to it the iframe
. The iframe
itself will load, thanks to the src
attribute, the mission.html
file that we've built with Twine.
Remember to hide the joystick when the iframe
is shown!
The story we've made with Twine is simple and short. It contains one interactive question where the player can choose the answer. If you'd like to use Twine in your game, you can start by importing and modifying the mission.html
file on your Twine account. Here is what it looks like:
When the Twine dialogue ends, the modal where it is displayed is removed
The final step is to remove the iframe
when the dialogue ends. But when?
The implementation is easy, thanks to the methods we've added in the DOMService:
public toggleIFrame(show: boolean):void {
this.domService.toggleElementVisibility('iframe-container', show);
this.domService.toggleElementVisibility('joystick', !show);
if (show) {
const config: HTMLElementConfig = {
id: 'iframe',
tagName: 'iframe',
classes: ['position-absolute', 'position-top', 'full-size'],
parentContainer: this.iFrameContainerElement,
attributes: [{ name: 'src', value: 'twinery-dialog/mission.html' }],
};
this.domService.createHTMLElement(config);
} else {
this.domService.removeElement('iframe');
}
}
The problem is not how to do it, the problem is when. Why? Because the iframe
should be removed when the dialogue ends. But at the moment the dialogue is hosted on an embedded HTML element that is not communicating with the main index.html
where our game is run. That is, at the moment we don't have a way to know when the toggleIFrame(false)
the method should be called. And we cannot call the IFrameService
from within the mission.html
javascript, since its scope does not include it (it's a different window
!). How to solve this problem?
Luckily for us, this is not a new problem. Somebody else went through it before us and came out with a solution, which is the Window.postMessage
API
. As the MDN documentation says,
The window.postMessage() method safely enables cross-origin communication between Window objects; e.g., between a page and a pop-up that it spawned, or between a page and an iframe embedded within it.
This is exactly what we are looking for. We are going to send a message from the iframe
window to the main window where the game is being executed. When the message arrives, we will close the iframe
by calling the IFrameService.toggleIFrame
method.
To use this solution we first need to listen for the events from the iframe
embedded window
:
@Service()
export class IFrameService {
/**
* The IFrame is listening for the CLOSE_IFRAME event. If you change this string here, it must be changed on the Twinery project too!
*/
private closeIframeTwinerySentEvent: string = 'CLOSE_IFRAME';
public init(): void {
this.iFrameContainerElement = this.domService.getElement('iframe-container');
this.initWindowListeners();
}
...
private initWindowListeners(): void {
window.onmessage = (event) => {
if (event.data === this.closeIframeTwinerySentEvent) {
this.toggleIFrame(false);
}
};
}
}
As you can see, the init
method listen for events and if they are of the right type (that is, sent from the iframe
containing the Twine dialogue, with a specific data string), then the toggleIFrame
method is called. We miss the very last piece. How do we send the message from the iframe
? That is, when should we call the Window.postMessage
API?
The easiest solution is to call the method when we are sure that the user has read the dialogue and is ready to play. We've already set up what we need: at the end of the dialogue, there is a big button saying: Play Sword Adventure! It's enough then to listen for click events on that button to send the message with the postMessage
API. We have to set this up on the Twine side. In the Twine project, we've done by adding some javascript on the last slice of the dialogue:
This is the result that we've obtained in the end:
Our Twine story is easy, but remember that in Twine stories can be as beautiful as you want, as you can see from their gallery!
Conclusions
In this tutorial we've integrated Twine, an open-source tool for telling non-linear stories, to add an interactive dialogue to our game. Now the mentor, responding to the user input, can tell the player what is his purpose: to reach the Sword and to take it! It is time then to start thinking about the logic of our level and to understand how to code it. This tutorial had a lot of stuff in it, the next one will be better: we're going to manage another integration, this time with the XState library!
In the next tutorial we will add the level logic to our game: the level is completed when the player takes the sword. We'll integrate the XState library for doing that!
Did you like this article and wish there were more? Donate on Paypal so that I can write more!