JavaScript + Ink: Part 5: Building an Interface

JavaScript + Ink:

ink is a scripting language for creating interactive narratives. It can be written using the editor Inky. The inkjs NPM module is a JavaScript port of the ink engine.


Building an Interface

In the previous part, the function EvaluateFunction() was used to call Ink functions directly, having Ink check and then change values. This was seen as a better alternative than changing values directly via the variablesState properties.

Back in the first part, the main.js file generated by the Ink for Web functionality showed a way of using Ink in a browser, using the same APIs as the examples developed from using the inkjs NPM module.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Example Story</title>
</head>
<body>
<div id="story"></div>
<script src="ink.js"></script>
<script src="ExampleStory.js"></script>
<script src="main.js"></script>
</body>
</html>
view raw index.html hosted with ❤ by GitHub

Pulling functionality and code examples from main.js from Ink for Web, its function usage can be copied and used to create a simple interface

var story = new inkjs.Story(storyContent);
var storyContainer = document.getElementById("story");
function continueStory() {
while(story.canContinue) {
// Get ink to generate the next paragraph
var paragraphText = story.Continue();
// Create paragraph element (initially hidden)
var paragraphElement = document.createElement('p');
paragraphElement.innerHTML = paragraphText;
storyContainer.appendChild(paragraphElement);
// Create HTML choices from ink choices
story.currentChoices.forEach(function(choice) {
// Create paragraph with anchor element
var choiceParagraphElement = document.createElement('p');
choiceParagraphElement.classList.add("choice");
choiceParagraphElement.innerHTML = `<a href='#'>${choice.text}</a>`
storyContainer.appendChild(choiceParagraphElement);
// Click on choice
var choiceAnchorEl = choiceParagraphElement.querySelectorAll("a")[0];
choiceAnchorEl.addEventListener("click", function(event) {
// Don't follow <a> link
event.preventDefault();
// Remove all existing choices
removeAll("p.choice");
// Tell the story where to go next
story.ChooseChoiceIndex(choice.index);
// Loop
continueStory();
});
});
}
}
// Remove all elements that match the given selector. Used for removing choices after
// you've picked one, as well as for the CLEAR and RESTART tags.
function removeAll(selector)
{
var allElements = storyContainer.querySelectorAll(selector);
for(var i=0; i<allElements.length; i++) {
var el = allElements[i];
el.parentNode.removeChild(el);
}
}
continueStory();
view raw main.js hosted with ❤ by GitHub
VAR x = 20
VAR y = 20
VAR water = 20
The desert was vast. My steps were erased soon after I placed them, but I had to continue. I knew that with every movement on the map, I would drink water — and I was quickly running out.
-> Choices
=== Choices ===
{ water < 1: -> Out_Of_Water }
+ Move north?
{ setY(y – 1) }
{ drinkWater() }
-> Choices
+ Move south?
{ setY(y + 1) }
{ drinkWater() }
-> Choices
+ Move west?
{ setX(x – 1) }
{ drinkWater() }
-> Choices
+ Move east?
{ setX(x + 1) }
{ drinkWater() }
-> Choices
=== Out_Of_Water ===
I shook my canteen, but could tell it was empty. This… this was the last step in my journey.
-> DONE
=== function setX(newX) ===
{
– newX > 100:
~ x = 100
– newX < 0:
~ x = 0
– else:
~ x = newX
}
=== function setY(newY) ===
{
– newY > 100:
~ y = 100
– newY < 0:
~ y = 0
– else:
~ y = newY
}
=== function currentPosition() ===
(Currently at {x} and {y}.)
=== function drinkWater() ===
~ water = water – 1
=== function currentWater() ===
(Remaining water {water})
view raw ExampleStory.ink hosted with ❤ by GitHub

Using a combination of canContinue, Continue() and currentChoices, the current text can be returned and the choices shown to the user. However, in using the EvaluateFunction() function, this can be improved.

Instead of showing choices, HTML buttons could be used to drive the story. These could call the internal Ink functions and change values.

Changing the interface means also changing the Ink code. If the HTML interface is going to drive the experience, there is little need for the choices to be in the Ink code.

VAR x = 20
VAR y = 20
VAR water = 20
The desert was vast. My steps were erased soon after I placed them, but I had to continue. I knew that with every movement on the map, I would drink water — and I was quickly running out.
-> Choices
=== Choices ===
{ water < 1: -> Out_Of_Water }
+ [Keep going]
-> Choices
=== Out_Of_Water ===
I shook my canteen, but could tell it was empty. This… this was the last step in my journey.
-> DONE
=== function goNorth() ===
{ setY(y – 1) }
{ drinkWater() }
=== function goSouth() ===
{ setY(y + 1) }
{ drinkWater() }
=== function goWest() ===
{ setX(x – 1) }
{ drinkWater() }
=== function goEast() ===
{ setX(x + 1) }
{ drinkWater() }
=== function setX(newX) ===
{
– newX > 100:
~ x = 100
– newX < 0:
~ x = 0
– else:
~ x = newX
}
=== function setY(newY) ===
{
– newY > 100:
~ y = 100
– newY < 0:
~ y = 0
– else:
~ y = newY
}
=== function currentPosition() ===
(Currently at {x} and {y}.)
=== function drinkWater() ===
~ water = water – 1
=== function currentWater() ===
(Remaining water {water})
view raw ExampleStory.ink hosted with ❤ by GitHub

In the new code, the other choices have been removed to create a simple looping structure where “Keep going” is the first, non-removed choice. Using HTML buttons, the previous Ink choices could be used to drive the code via calls to EvaluateFunction(), making a HTML interface to the Ink interface functions.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Example Story</title>
</head>
<body>
<div id="story"></div>
<button id="north">Go North</button>
<button id="south">Go South</button>
<button id="east">Go East</button>
<button id="west">Go West</button>
<div id="water"></div>
<div id="position"></div>
<script src="ink.js"></script>
<script src="ExampleStory.js"></script>
<script src="main.js"></script>
</body>
</html>
view raw index.html hosted with ❤ by GitHub

This requires re-writing the main.js code to support this new interface where, instead of using currentChoices, the same choice is used over and over again whenever a BUTTON is clicked.

var story = new inkjs.Story(storyContent);
var storyContainer = document.getElementById("story");
var waterElement = document.getElementById("water");
var positionElement = document.getElementById("position");
var northElement = document.getElementById("north");
northElement.addEventListener("click", function(event) {
story.EvaluateFunction("goNorth");
story.ChooseChoiceIndex(0);
continueStory();
});
var southElement = document.getElementById("south");
southElement.addEventListener("click", function(event) {
story.EvaluateFunction("goSouth");
story.ChooseChoiceIndex(0);
continueStory();
});
var westElement = document.getElementById("west");
westElement.addEventListener("click", function(event) {
story.EvaluateFunction("goWest");
story.ChooseChoiceIndex(0);
continueStory();
});
var eastElement = document.getElementById("east");
eastElement.addEventListener("click", function(event) {
story.EvaluateFunction("goEast");
story.ChooseChoiceIndex(0);
continueStory();
});
function refreshWater() {
waterElement.innerHTML = story.EvaluateFunction("currentWater", [], true).output;
}
function refreshPosition() {
positionElement.innerHTML = story.EvaluateFunction("currentPosition", [], true).output;
}
function continueStory() {
// Get ink to generate the next paragraph
var paragraphText = story.Continue();
// Create paragraph element (initially hidden)
var paragraphElement = document.createElement('p');
paragraphElement.innerHTML = paragraphText;
storyContainer.appendChild(paragraphElement);
refreshWater();
refreshPosition();
}
continueStory();
view raw main.js hosted with ❤ by GitHub