@eit6609/storyteller
v1.0.7
Published
An interactive ebooks generator
Downloads
11
Maintainers
Readme
Storyteller, a generator of interactive ebooks
Storyteller is a tool for the generation of game ebooks that, given
- a set of XHTML templates representing the locations of the game
- a class containing the data and implementing the actions of the game
generates an ePUB containing all the possible situations of the game. The player then plays the game by following the links.
It was inspired by the Medusa compiler of Enrico Colombini, which was used to create his Locusta Temporis interactive ebook.
Install it by running:
npm i @eit6609/storyteller
And use it like this:
const Storyteller = require('@eit6609/storyteller');
const options = ...
const storyteller = new Storyteller(options)
const initialTemplateName = ...
const initialState = ...
await storyteller.generate(initialTemplateName, initialState);
Why interactive ebooks?
You can find the details behind the idea in Colombini's Interactive Fiction & ebooks: Designing puzzles for digital books, and an introduction in the slides about Medusa from Lua workshop 2014.
With an interactive ebook you don't need an engine to play the game, because the game has been pre-played by Storyteller. All you need is an ebook reader.
Since there are ebook readers for every device you get the maximum portability.
Of course there are some limitations in the design of the game:
- the actions can be performed only by following links
- you cannot use random generators or unlimited counters
- you must avoid a combinatorial explosion
Interactive Fiction & ebooks deals thoroughly with these issues, however I give you some tips & tricks at the end of this document.
There are also some limits for the player:
- the player must only follow the links and cannot navigate the book by freely turning the pages
This limitations notwithstanding, you can create amazing games.
If you try Locusta Temporis you won't believe your eyes. And it was created with Medusa, which is functionally equivalent to Storyteller.
You can find some example ePUBs in the examples.
How does it work?
The state class
The logic of the game is implemented with a class that contains the state of the game and the methods to access and manipulate the state.
Let's see an example, that is included in the complete examples:
class Hanoi {
static configure (config) {
Hanoi.config = config;
}
constructor () {
this.reset();
}
reset () {
this.pegs = [
new Peg(Hanoi.config.nDiscs),
new Peg(),
new Peg()
];
}
getConfig () {
return Hanoi.config;
}
isFinished () {
return this.pegs[2].discs.length === Hanoi.config.nDiscs;
}
canMove (from, to) {
from = this.pegs[from];
to = this.pegs[to];
return from.discs.length > 0 && (to.discs.length === 0 || to.getTopDisc() > from.getTopDisc());
}
move (from, to) {
this.pegs[to].discs.push(this.pegs[from].discs.pop());
}
}
In this state class, the property pegs
and the methods getConfig()
, canMove()
and isFinished()
let you access
the state to display information and make decisions in the template.
The methods reset()
and move()
are modifiers, and will be used with the goto()
function, explained later.
The templates
The pages of the ebook are generated by a set of XHTML templates, one for every location of the game.
After many experiments with the most popular templating engines for Node.js, I have chosen Pug because it gives you enough freedom to call JavaScript code inside the template. This is vital, but many engines (the very popular Handlebars among others) make the call of methods on a class instance a nightmare.
Of course EJS gives you complete freedom, but I prefer higher level engines like Pug.
I have added experimental support for markdown templating by means of my Markdown Templates engine.
Let's see an example of template:
doctype strict
html(xmlns='http://www.w3.org/1999/xhtml', xml:lang='en')
head
link(href="style-epub.css", rel="stylesheet", type="text/css")
title The Tower of Hanoi
body
h3 The Tower of Hanoi
hr
p!= debug()
p.first The situation is:
ul
li Peg 1: #{state.pegs[0]}
li Peg 2: #{state.pegs[1]}
li Peg 3: #{state.pegs[2]}
if state.isFinished()
h1 YOU’VE WON!
br
p.first Want to #[a(href=goto((state) => state.reset())) play again]?
else
p.first Possible moves are:
ul
if state.canMove(0, 1)
li #[a(href=goto((state) => state.move(0, 1))) 1 ==> 2]
if state.canMove(0, 2)
li #[a(href=goto((state) => state.move(0, 2))) 1 ==> 3]
if state.canMove(1, 2)
li #[a(href=goto((state) => state.move(1, 2))) 2 ==> 3]
if state.canMove(2, 1)
li #[a(href=goto((state) => state.move(2, 1))) 3 ==> 2]
if state.canMove(2, 0)
li #[a(href=goto((state) => state.move(2, 0))) 3 ==> 1]
if state.canMove(1, 0)
li #[a(href=goto((state) => state.move(1, 0))) 2 ==> 1]
p.first Of course you can also #[a(href=goto((state) => state.reset())) restart the game].
p.first Or you may want to review the #[a(href=goto('start', (state) => state.reset())) instructions].
hr
As you can see the accessors are used to display data:
li Peg 1: #{state.pegs[0]}
and to make decisions:
if state.canMove(0, 2)
The modifiers are used in the links, with the goto()
function, to perform actions:
li #[a(href=goto((state) => state.move(2, 0))) 3 ==> 1]
p.first Or you may want to review the #[a(href=goto('start', (state) => state.reset())) instructions].
The generator
Following the links
Starting from a template and a state, the generator recursively follows all the links, generating all the possible pages.
This is done with the goto()
function, provided by the context of the template.
You can pass goto()
a template name and/or a function (called action) that modifies the state. The action usually
just calls a modifier method on the state.
As the action is optional, it is possible to change only the template (that is, the location) and keep the state as is.
As the template name is optional, it is possible to change only the state without changing the location.
Generating the pages
The combination of a template and a state generates a page:
template + state = page
To keep track of the generated pages, every state instance is reduced to a hash, which is a human readable string that uniquely identifies the state instance. It's a kind of compact JSON, that can deal also with the situations that are not handled by JSON, like circular references and the new data structures of ES6, Maps and Sets.
A template name and a state hash uniquely identify a page:
template name + hash(state) = page key
For debug and learning purposes, you can use the debug()
function inside a template to show the page key. Because the
hash is human readable, it is a meaningful representation of the state. It can be very useful to understand why the
template engine has generated the page as it is.
API reference
The generator class
constructor(options?: object)
These are the supported options:
templatesDir
, string, required: the path of the directory containing the templatesoutputDir
, string, required: the path of the directory to use for the generated XHTML files. This directory is used as input for the ePUB creator, so you can put in this directory any extra file (images, stylesheets) that you need in the ePUB.metadata
, object, required, the options for the ePUB creator, with these properties:title
, string, optional, defaultuntitled
: the title of the ePUBauthor
, string, optional, default no author: the author of the ePUBlanguage
, string, optional, defaulten
: the language of the ePUBcover
, string, optional, default no cover: a path relative tooutputDir
of an image that will become the cover of the ePUBfilename
, string, required: the path of the generated ePUB
markdown
, boolean, optional, defaultfalse
: iffalse
the template engine is Pug, otherwise it is Markdown Templatesdebug
, boolean, optional, defaultfalse
: iftrue
thedebug()
function called in the templates will return the page key, otherwise the empty string.contentBefore
, array, optional, default[]
. Extra, static pages to insert into the generated ePUB before the generated pages. The items of the array are objects with these properties:fileName
, string, required. The path of the file relative tooutputDir
.tocLabel
, string, optional. The label to use in the TOC. Leave it out if you don't want the page to be added to the TOC.
contentAfter
, array, optional, default[]
. Extra, static pages to insert into the generated ePUB after the generated pages. The items of the array are the same ascontentBefore
.
generate(initialTemplateName: string, initialState: object): promise
It generates the ePUB given:
initialTemplateName
: the path of the initial template file, without the extension, relative to thetemplatesDir
initialState
: the initial state instance
It returns a promise with no value.
The template context
These are the properties of the "locals" of the template engine.
state
It is the current state instance, that can be used to call its accessors to display data and make decisions.
debug(): string
If the debug
option is true
, this function returns the key of the current page, that is the template name and the
state hash, wrapped in a <code>
element. Otherwise it returns the empty string. You can place its result wherever you
like in the templates to display the info, and then simply disable it by setting the debug
option to false
without
modifying the templates.
goto(templateName?: string, action?: function): string
The purpose of this function is to ask the generator the URL of the page identified by the template name and the hash of
the state you get by applying the action to a copy of the current state.
The function returns the URL of the requested page, that can be used in the href
of an a
to create a link.
The parameters are:
templateName
: the path of a template file, without the extension, relative to thetemplatesDir
. It defaults to the current template's name.action
: a function that receives a state as its only parameter and modifies it. It should return a falsy value unless it wants to replace the received state with a new one: in this case it should return the new state. This is handy for complex games because it enables you to move through independent stages of the game. More about this later, in the Tips & Tricks section.
With this function you actually trigger the generation of the pages, because, if the requested page does not exist,
the generator creates an empty page and enqueues it for the build, that is the execution of the template with the state
of the page. That execution could find and execute some goto()
that could trigger the creation of new pages, and so
on.
Examples
You can generate the example ebooks by moving to the code
folder and running the main.js
script:
node main.js
You can change the debug options to true
to see who (template + state) generated the pages.
You can inspect the generated XHTML files, they are in the out
subdirectories.
But if you are lazy you can just download the generated ePUBs.
Goat, Cabbage & Wolf
A classic puzzle!
There are two scripts:
main-xhtml.js
, that uses the Pug (.pug) templatesmain-markdown.js
, that uses the experimental Markdown Templates (.md) templates
The generated ePUBs should be the same.
You can download the generated ePUB here.
Desert Traversal
A puzzle about managing scarce resources.
You can download the generated ePUB here.
The Tower of Hanoi
Not much of an adventure game, but I have loved this game when I learned about recursion and I think that it is a very neat example.
You can download the generated ePUB here.
Tips & Tricks
Keep the state "small"
The golden rule to avoid combinatorial explosion is:
You must keep the state as empty as possible
Let's see some examples.
Be smart
You can implement a combination lock by using a simple boolean:
true
: "all digits right so far"false
: "at least one wrong digit"
This way you won't need to handle the exponential number of cases.
For example, you can use this state:
class Safe {
constructor (combination) {
this.combination = combination;
this.ok = true;
this.index = 0;
}
choose (digit) {
this.ok = String(digit) === this.combination.charAt(this.index);
this.index++;
}
isRight () {
return this.index === this.combination.length && this.ok;
}
isWrong () {
return this.index === this.combination.length && !this.ok;
}
}
with this template:
doctype strict
html(xmlns='http://www.w3.org/1999/xhtml', xml:lang='en')
head
link(href="style-epub.css", rel="stylesheet", type="text/css")
title Open the Safe
body
h3 Open the Safe
hr
if state.isRight()
h1 YOU’VE WON!
else if state.isWrong()
h1 YOU’VE LOST!
else
p.first Choose digit ##{state.index + 1}:
ul
- for (let i = 0; i < state.combination.length; i++)
li #[a(href=goto((s) => s.choose(i))) #{i}]
hr
The generated pages will be only n (the correct combination) + n (all the possible wrong combinations), where n is the length of the combination.
Why? Because what determines the number of pages are the possible values of the properties of the state, and they are 2n:
- 2 for
ok
- n for
index
For the sake of precision, there are only 2n - 1 pages, because the state {index=0,ok=false}
is not used.
Consume objects
Almost every adventure game has a basket where the player can put the objects found around.
However, keeping objects in the basket is very expensive, because you'll have 2 ^ n possible states if you need to keep track of n objects.
You should prefer objects that get consumed, and drop them as soon as you can.
Partition the game
You should partition the game in several independent stages, in order to reset the state when you finish one stage and enter another.
You should implement the different stages with different state classes, and then exploit the feature of the generator that uses the new state returned by an action to make the transition from one stage to another.