'use strict';'use strict';Gist-txt is a minimal text adventure engine that helps game designers to create text adventures from GitHub gists.
To create a new text adventure just create a new public gist at
https://gist.github.com/ with at least a markdown file named
index.markdown. This will be the main scene: the starting point of the
adventure.
Get more info at https://github.com/potomak/gist-txt.
var gistId;
var currentScene;
var currentTrack;
var files;
var cache = {};
var loaded = false;
window.state = {};
var VERSION = require('./package.json').version;
var $ = require('jquery');
var mustache = require('mustache');
var marked = require('marked');
var yfm = require('yfm');
var q = require('q');
var initUI;
var applyStylesheet;
var loadAndRender;
var compileAndDisplayFooter;
var getFileContent;
var extractYFM;
var injectSceneStyle;
var cacheContent;
var renderMustache;
var renderMarkdown;
var outputContent;
var handleInternalLinks;
var playTrack;
var playSceneTrack;
var updateGameState;
var runSceneInit;
var runScene;
var parse;
var toggleError;
var toggleLoading;
var isDev;During the initialization stage the hash portion of the path get parsed to extract two main information:
Using the gist id a GET request to https://developer.github.com/v3/gists/#get-a-single-gist is made to get gist’s data.
If gist id is set to DEV the files variable is set to an arbitrary value
and subsequent requests will be sent to the /dev path.
A successful response triggers the loading and rendering of the selected scene.
var init = function () {
loaded = true;
var scene = parse(document.location.hash);
if (isDev()) {
files = {};
initUI(scene);
return;
}
return q($.getJSON('https://api.github.com/gists/' + gistId))
.then(function (gist) {
files = gist.files;
return initUI(scene);
}, function (xhr) {
toggleLoading(false);
toggleError(true, xhr.statusText);
});
};UI initialization consists of three steps:
initUI = function (scene) {
return applyStylesheet()
.then(loadAndRender.bind(this, scene))
.then(compileAndDisplayFooter);
};If gist’s files include a style.css its content is used to determine the
overall CSS style for the story.
The method returns a promise that is always resolved (the stylesheet is optional).
applyStylesheet = function () {
var deferred = q.defer();
if (files['style.css'] !== undefined) {
q($.get(files['style.css'].raw_url))
.then(function (content) {
$('<style>')
.attr('type', 'text/css')
.html(content)
.appendTo('head');
})
.fin(deferred.resolve);
} else {
deferred.resolve();
}
return deferred.promise;
};The cache object is inspected to retrieve an already compiled scene file,
otherwise the load and render process include:
raw_urlstate objectcache objectinit function to initialize scene if presentdiv#content elementThe process continues adding click handlers to link in the content, to
handle navigation between scenes.
If everything goes well current scene name is associated to the global
variable currentScene, then previous scene’s stylesheet is disactivated and
current scene’s stylesheet is activated.
loadAndRender = function (scene) {
toggleError(false);
toggleLoading(true);
var promise;
if (cache[scene] !== undefined) {
promise = q.fcall(runSceneInit.bind(this, cache[scene]));
} else {
promise = getFileContent(scene)
.then(extractYFM.bind(this, scene))
.then(cacheContent.bind(this, scene))
.then(runSceneInit);
}
return promise
.then(playTrack)
.then(updateGameState)
.then(renderMustache)
.then(renderMarkdown)
.then(outputContent)
.then(handleInternalLinks)
.then(function () {
$('body').scrollTop(0);
$('#' + currentScene + '-style').prop('disabled', true);
$('#' + scene + '-style').prop('disabled', false);
currentScene = scene;
})
.catch(toggleError.bind(this, true))
.fin(toggleLoading.bind(this, false));
};If the gist response is successful the footer gets compiled and displayed to show:
compileAndDisplayFooter = function () {
$('a#source')
.attr('href', 'https://gist.github.com/' + gistId)
.html(gistId);
$('span#version').html(VERSION);
$('footer').show();
};Every scene is associated with a Markdown gist’s file in the form:
scene + '.markdown'
where scene is the name of the scene.
A GET request to file’s raw_url is made and if successful it resolves the
deferred object with the result content as argument of the callback.
getFileContent = function (scene) {
var deferred = q.defer();
var fileName = scene + '.markdown';
var file = files[fileName];
if (!isDev() && file === undefined) {
deferred.reject(new Error('Scene not found'));
return deferred.promise;
}
var fileURL;
if (isDev()) {
fileURL = '/dev/' + fileName;
} else {
fileURL = file.raw_url;
}
q($.get(fileURL))
.then(deferred.resolve)
.catch(function (xhr) {
throw new Error(xhr.statusText);
});
return deferred.promise;
};The YAML Front Matter is extracted from the original content. The resulting
context is used to extend the global state.
If context’s style property is defined a <style> tag with the content of
the property is injected to override global stylesheet rules.
extractYFM = function (scene, content) {
var parsed = yfm(content);
if (parsed.context.style !== undefined) {
injectSceneStyle(scene, parsed.context.style);
}
return parsed;
};To inject a scene’s stylesheet a <style> element with the id attribute in
the form:
scene + '-style'
get appended into the <head> of the HTML document.
Scene stylesheets are disabled on scene transitions and re-enabled on new visits of the scene.
injectSceneStyle = function (scene, content) {
$('<style>')
.attr('id', scene + '-style')
.attr('type', 'text/css')
.html(content)
.appendTo('head');
};Play a track associated to the current scene.
If there’s a track already playing it fades its volume and start playing the current one.
Audio files should be included in two formats: ogg and mp3.
To play a track in a scene add the track key to current scene’s YAML
header and set its value to the name of the audio file without the
extension.
playTrack = function (parsed) {
if (parsed.context.track !== undefined) {
if (currentTrack !== undefined && !currentTrack.paused) {
$(currentTrack).animate({ volume: 0 }, 1000, playSceneTrack.bind(this, parsed.context.track));
} else {
playSceneTrack(parsed.context.track);
}
}
return parsed;
};An helper function that creates a new Audio element with the autoplay
and loop attributes set to true and loads a file audio based on current
browser audio capabilities.
playSceneTrack = function (track) {
var ext = (new Audio().canPlayType('audio/ogg; codecs=vorbis')) ? 'ogg' : 'mp3';
var filename = track + '.' + ext;
var track = new Audio();
track.autoplay = true;
track.loop = true;
track.src = files[filename].raw_url;
currentTrack = track;
};Mustache content is rendered using game’s global state (window.state) as
its view object.
renderMustache = function (content) {
return mustache.render(content, window.state);
};Markdown content is rendered.
renderMarkdown = function (content) {
return marked(content);
};The HTML rendered content is the main content of the scene. It gets appended
to the #content element in the DOM.
The returning promise fulfills after the content string has been inserted
in the DOM.
outputContent = function (content) {
return q($('#content').html(content).promise());
};Caching content prevents waste of API calls and band for slow connections.
The cache consists of a simple JavaScript object that contains gist’s files parsed content indexed by scene name.
cacheContent = function (scene, content) {
cache[scene] = content;
return content;
};Internal links click events are overridden to handle navigation between scenes of the text adventure.
<a> elements’ href attribute is used to rewrite location’s hash in the
form:
'#' + gistId + '/' + href
At every internal link click event a new state get pushed in the
window.history object to allow navigation using back and forward buttons.
handleInternalLinks = function (contentElement) {
contentElement.find('a').click(function (event) {
event.preventDefault();
var hash = '#' + gistId + '/' + $(this).attr('href');
runScene(hash);
window.history.pushState(null, null, document.location.pathname + hash);
});
};Extends game state with current scene’s state.
updateGameState = function (parsed) {
$.extend(window.state, parsed.context.state);
return parsed.content;
};Run a scene initialization function.
runSceneInit = function (parsed) {
if (parsed.context.init !== undefined) {
parsed.context.init();
}
return parsed;
};A popstate event is dispatched to the window every time the active history
entry changes between two history entries for the same document.
If the files array is undefined we need to initialize the text adventure,
otherwise we can just render the current scene.
window.onpopstate = function () {
if (!loaded) {
return init();
}
if (files !== undefined) {
runScene(document.location.hash);
}
};Running a scene includes:
runScene = function (hash) {
var scene = parse(hash);
loadAndRender(scene);
};A gist-txt location hash has the form:
#<gist-id>/<scene>
To parse the hash:
gistIdNote: gists’ files can’t include the ‘/‘ character in the name so, even if the remaining portion of the segments array is joined by ‘/‘, that array should always contain at most one element.
If the scene name is blank return ‘index’, the default name of the main scene, otherwise return the scene name found.
parse = function (hash) {
var path = hash.slice(1);
var segments = path.split('/');
gistId = segments.shift();
var scene = segments.join('/');
if (scene === '') {
return 'index';
}
return scene;
};toggleError and toggleLoading help showing error and loading messages.
toggleError = function (display, errorMessage) {
$('#error').html('Error: ' + errorMessage).toggle(display);
};
toggleLoading = function (display) {
$('#loading').toggle(display);
};During development you can use the DEV special gist id to bypass requests
to the local development server to the /dev path.
isDev = function () {
return gistId === 'DEV';
};$(init);