/** * dedalus.js v0.9.4 * 2013, Gustavo Di Pietro * Licensed under the GPL license (http://www.gnu.org/licenses/gpl-2.0.html) **/ /* Dedalus defines the core functionalities and a base class that must be * implemented by a Dedalus implementation. * * Its constructor loads source story and defines a global "story" variable * that can be openly accessed at runtime by the story script. * Dedalus depens on: * jQuery: http://jquery.com/ * doT.js: http://olado.github.io/doT/index.html */ /* ** STRING EXTENSIONS ** */ if (!String.prototype.trim) String.prototype.trim = function () { return this.replace(/^\s+|\s+$/g, ''); }; if (!String.prototype.ltrim) String.prototype.ltrim = function () { return this.replace(/^\s+/,''); }; if (!String.prototype.rtrim) String.prototype.rtrim = function () { return this.replace(/\s+$/,''); }; if (!String.prototype.startsWith) String.prototype.startsWith = function (str) { return this.lastIndexOf(str, 0) === 0; }; var Dedalus, story; (function () { "use strict"; /*jslint evil: true, white: true, nomen: true */ /*global $, jQuery, doT*/ /** * Base class Dedalus * @param {JSON} options A dictionary of configuration options: * { * domSource: A jQuery object with the root * element containing the story * source * dedleeSource: If this jQuery element is set, * assume that the story is in dadlee * format and hosted in this element * } * @return A Dedalus instance */ Dedalus = function (options) { // Ignore parsing the story source if called as prototype constructor by // a class inheriting from this this.options = options; if (this.options) { this.dedleeSource = this.options.dedleeSource; this.domSource = this.options.domSource; this._story = this.options ? this.parseStory() : {}; } // Make sure that story is not undefined this._story || (this._story = {}); // Variables to store previous running states to be restored by undo // actions this.storyUndo = {}; this._storyUndo = {}; // Publish / Subscribe dispatcher this.messageCenter = makeMessageCenter(); // Set global story story = this.generateEmptyStory(); }; /** * Parse the story file passed by domSource * @param {jQuery} domSource A jQuery object with the root element * containing the story source * * @return {JSON} An object containing all the information * about the story. This is different from the global * story variable since its contains more infos that * are not shared outside the class */ Dedalus.prototype.parseStory = function () { var _story = { currentPage : '', title : '', inventory : [], pages : {}, paragraphs : {}, objects : {}, initialization : '', intro : '', numParagraphsShown : 0, numPagesTurned : 0, numParagraphsShownInPage : 0, numTotalActions : 0, numActionsPerformedInPage : 0, combinationActionInProgress : false, beforeEveryThing : function () {}, beforeEveryPageTurn : function () {}, beforeEveryParagraphShown : function () {}, afterEveryThing : function () {}, afterEveryPageTurn : function () {}, afterEveryParagraphShown : function () {} }, temporaryDomSourceCopy; /** * Get the title of the story * @param {jQuery} parent Element containing * @return {String} The title of the story */ function getTitleLocal (parent) { return parent.find('title').html() || "A Dedalus Story"; } /** * Get the custom javascript code to be executed when the story starts * running * @param {jQuery} parent Element containing <initscript> * @return {String} Javascript source code to be executed in the * story initialization fase */ function getInitScript (parent) { return parent.find('initscript').text(); } /** * Get the html to be present to the player (only once) as introductory * text * @param {jQuery} parent Element containing <id="#intro"> * @return {String} HTML to be presented */ function getIntroLocal (parent) { return parent.find('#intro').html(); } /** * Get all the objects and character elements defined within parent. Parent * can be either the top level story or one of its sub elements (for example * get the objects defined within a page) * text * @param {jQuery} parent Element to be searched for <obj> and <character> * @return {JSON} A dictionary mapping the objects found: * { * 'Object Name': { * actions: { * 'Action Name': { * when: Function (returning a bool) determining whether * the action must be presented in a given context * content: doT template to be printed when the action is executed * with: { "with" combinations * 'Object to combine with': doT template to be printed when the action is executed * }, * hasWith: Boolean: true if "with" has any value * } * } * inventoryName: Name to be used to present * the object once added to * the inventory * getActiveActions: Function returning the actions active in a given moment * (determined by calling "when" of actions) * } * } */ function getObjects (parent) { var objects = {}; parent.find('obj, character').each(function () { var obj = $(this), id = obj.attr('id'), inventoryName = obj.attr('inventoryName'); objects[id] = { actions : {}, inventoryName : inventoryName }; /** * Run "when" on every object action and return those for which * it is true * @return {JSON} A dictionary of active action objects */ function getActiveActions () { var action, actions = {}, object = objects[id]; for (action in object.actions) { if (object.actions.hasOwnProperty(action)) { if (object.actions[action].when()) { actions[action] = object.actions[action]; } } } return actions; } // object actions obj.find('action').each(function () { var whenCheck = function () { return true; }, action = $(this), when = action.find('when').text() || undefined, withCombinations = {}, hasCombinations = false; if (when !== undefined) { whenCheck = function () { return eval(when); }; } action.find('when').remove(); // combination actions ("with" interactions) action.find('with').each(function () { var withElement = $(this), withPartner = withElement.attr('id'), withContent = doT.template(Dedalus.getRawContent(withElement)); withCombinations[withPartner] = withContent; hasCombinations = true; withElement.remove(); }); objects[id].actions[action.attr('id')] = { 'when' : whenCheck, 'content' : doT.template(Dedalus.getRawContent(action)), 'hasWith' : hasCombinations, 'with' : withCombinations }; }); objects[id].getActiveActions = getActiveActions; }); return objects; } /** * Get all the paragraphs within parent. Parent can be either the top level * story or one of its pages * @param {jQuery} parent Element to be searched for <paragraph> * @return {JSON} A dictionary of paragraph objects: * { * 'Paragraph Name': { * content: a doT template for later execution * } * } */ function getParagraphs (parent) { var paragraphs = {}; parent.find('paragraph').each(function () { var paragraph = $(this), id = paragraph.attr('id'); paragraphs[id] = { content: doT.template(Dedalus.getRawContent(paragraph)) }; }); return paragraphs; } /** * Get all the paragraphs within parent * @param {jQuery} parent Element to be searched for <page> * @return {JSON} A dictionary of page objects: * { * 'Page Name': { * content: a doT template for later execution * objects: a dictionary of objects (see above) * only accesible from within the page * paragraphs: a dictionary of paragraphs (see above) * only accesible from within the page * isFirst: true if this is the first page to be * shown at startup after displaying the * intro (<class="first">) * } * } */ function getPages (parent) { var pages = {}; parent.find('page').each(function () { var page = $(this), id = page.attr('id'); pages[id] = { objects : getObjects(page), paragraphs : getParagraphs(page), isFirst : page.hasClass('first') }; page.find('obj, paragraph, character').remove(); pages[id].content = doT.template(Dedalus.getRawContent(page)); }); return pages; } /** * Get the d of the page defined as first to be displayed after the * intro * @param {jQuery} parent Element to be searched for <page class="first"> * @return {String} Id of the page */ function getInitialCurrentPage (parent) { return parent.find('page.first').attr('id'); } /** * Get the initial inventory (empty) * @return {Array} Empty array */ function getInitialInventory () { return []; } /** * Set up _story functions for timed events to be executed before or after * page turns, paragraph showing and so on * @param {jQuery} parent Element to be searched for: * <beforeEveryThing> * <beforeEveryPageTurn> * <beforeEveryParagraphShown> * <afterEveryThing> * <afterEveryPageTurn> * <afterEveryParagraphShown> */ function setBeforeAfterActions (parent) { _story.beforeEveryThing = parent.find('beforeEveryThing').text(); _story.beforeEveryPageTurn = parent.find('beforeEveryPageTurn').text(); _story.beforeEveryParagraphShown = parent.find('beforeEveryParagraphShown').text(); _story.afterEveryThing = parent.find('afterEveryThing').text(); _story.afterEveryPageTurn = parent.find('afterEveryPageTurn').text(); _story.afterEveryParagraphShown = parent.find('afterEveryParagraphShown').text(); } // If the story is in dadlee format, first convert it into the normal // HTML-like format if (this.dedleeSource) { this.parseDedlee(this.dedleeSource, this.domSource); } // Make a temporary copy of domSource so that the destructive process // of parsing it can be restored temporaryDomSourceCopy = this.domSource.children().clone(true); // Prepare _story and returns it _story.currentPage = getInitialCurrentPage(this.domSource); _story.initialization = getInitScript(this.domSource); _story.intro = getIntroLocal(this.domSource); _story.inventory = getInitialInventory(this.domSource); _story.objects = getObjects(this.domSource); _story.pages = getPages(this.domSource); _story.paragraphs = getParagraphs(this.domSource); _story.title = getTitleLocal(this.domSource); setBeforeAfterActions(this.domSource); // Restore untouched domSource this.domSource.html('').append(temporaryDomSourceCopy); return _story; }; /** * Generate a dictionary with the public methods of the global "story" object * @return {JSON} A dictionary representing the initial "story" variable */ Dedalus.prototype.generateEmptyStory = function () { return { currentPageIs : this.currentPageIs.bind(this), removeFromInventory : this.removeFromInventory.bind(this), putInInventory : this.putInInventory.bind(this), isInInventory : this.isInInventory.bind(this), getNumTotalActions : this.getNumTotalActions.bind(this), getNumActionsPerformedInPage : this.getNumActionsPerformedInPage.bind(this), getNumPagesTurned : this.getNumPagesTurned.bind(this), getNumParagraphsShown : this.getNumParagraphsShown.bind(this), getNumParagraphsShownInPage : this.getNumParagraphsShownInPage.bind(this), turnTo : this.turnTo.bind(this), disable : this.disable.bind(this), enable : this.enable.bind(this), showParagraph : this.showParagraph.bind(this), endGame : this.endGame.bind(this) }; }; /** * Main method for the handle of output. Its concrete realization is implementation * specific and is delegated to executePrinting() * @param {doT} content A doT template function with the contend to display * @param {Bool} turnPage True if the printing must generate a change of page */ Dedalus.prototype.print = function (content, turnPage) { // Saves the current application state before changin it in order to gather // data for the undo this.storyUndo = jQuery.extend(true, {}, story); this._storyUndo = jQuery.extend(true, {}, this._story); this.afterUndoSave(); this.executeBeforeEveryThing(); this.executePrinting(content, turnPage); this.executeAfterEveryThing(); this._story.numTotalActions += 1; this._story.numActionsPerformedInPage += 1; }; /** * Display a page. * @param {String} target Id of the page to present * @param {Bool} noTurn Whether the page must generate a page turn. Defauls * to true */ Dedalus.prototype.turnTo = function (target, noTurn) { var isNoTurn = noTurn || false, pageToPrint = this.getPage(target), content = pageToPrint.content; this.executeBeforeEveryPageTurn(); if (!isNoTurn) { this.print(content, true); // Set current page unless it is the intro page that must be shown // only once and right before page.first if (target !== 'intro') { this.setCurrentPageId(target); } } else { this.print(content); } this.executeAfterEveryPageTurn(); // Update counters. this._story.numParagraphsShownInPage = 0; this._story.numActionsPerformedInPage = 0; this._story.numPagesTurned += 1; }; /** * Display a paragraph. * @param {String} target Id of the paragraph to present */ Dedalus.prototype.showParagraph = function (target) { var paragraphToPrint = this.getParagraph(target), content = paragraphToPrint.content; this.executeBeforeEveryParagraphShown(); this.print(content); this.executeAfterEveryParagraphShown(); // Update counters. this._story.numParagraphsShown += 1; this._story.numParagraphsShownInPage += 1; }; /** * Return the page object with a given id * @param {String} id Id of the page to return * @return {JSON} Page object */ Dedalus.prototype.getPage = function (id) { return this._story.pages[id]; }; /** * Return the object with a given id. Try to search it within the current * page objects array and if not found return the top level one * @param {String} id Id of the object to return * @return {JSON} Object found */ Dedalus.prototype.getObject = function (id) { var maybeObject = this.getCurrentPage().objects[id]; return maybeObject !== undefined ? maybeObject : this._story.objects[id]; }; /** * Get the active actions for the given object in a context * @param {String} id Id of the object whose actions are to return * @return {JSON} Dictionary of active actions (see parseStory.getObjects) */ Dedalus.prototype.getActionsForObject = function (id) { var action, actions = [], object = this.getObject(id); // filter actions whose "when" parameter is currently true for (action in object.actions) { if (object.actions.hasOwnProperty(action)) { if (object.actions[action].when()) { actions.push(object.actions[action]); } } } return actions; }; /** * Return the paragraph with a given id. Try to search it within the current * page paragraphs array and if not found return the top level one * @param {String} id Id of the paragraph to return * @return {JSON} Paragraph found */ Dedalus.prototype.getParagraph = function (id) { var maybeParagraph = this.getCurrentPage().paragraphs[id]; return maybeParagraph !== undefined ? maybeParagraph : this._story.paragraphs[id]; }; /** * Keep track of an ongoind combination action */ Dedalus.prototype.activateCombinationAction = function () { this._story.combinationActionInProgress = true; }; /** * Stop keeping track of an ongoind combination action */ Dedalus.prototype.disactivateCombinationAction = function () { this._story.combinationActionInProgress = false; }; /** * Whether a combination action is awaiting for a target * @return {Boolean} true if there is a combination action awaiting * for a target */ Dedalus.prototype.isCombinationAction = function () { return this._story.combinationActionInProgress; }; /** * Check if the current page is the provided one * @param {String} page Id of the page to check against * @return {Bool} Whether current page id is the given page id */ Dedalus.prototype.currentPageIs = function (page) { return this._story.currentPage === page; }; /** * Remove an item from the inventory and trigger a "modified invetory" event * on the message center * @param {String} object Id of the object to remove */ Dedalus.prototype.removeFromInventory = function (object) { var i; for (i = 0; i < this._story.inventory.length; i += 1) { if (this._story.inventory[i] === object) { this._story.inventory = this._story.inventory.splice(i, i); } } this.messageCenter.publish('inventory', 'inventoryChanged'); }; /** * Add an item to the inventory and trigger a "modified invetory" event * on the message center * @param {String} object Id of the object to add */ Dedalus.prototype.putInInventory = function (object) { this.removeFromInventory(object); this._story.inventory.push(object); this.messageCenter.publish('inventory', 'inventoryChanged'); }; /** * Check if an object is currenlty in the inventory * @param {String} object Id of teh object to check the presence of * @return {Bool} Whether the object is in the inventory */ Dedalus.prototype.isInInventory = function (object) { return this._story.inventory.indexOf(object) !== -1; }; /** * Run the initialization javascript peculiar to the story being executed */ Dedalus.prototype.executeInitialization = function () { eval(this._story.initialization); }; /** * Return the introductory content of the story * @return {String} HTML to be present at startup */ Dedalus.prototype.getIntro = function () { return this._story.intro; }; /** * Return the stry title * @return {String} Story title */ Dedalus.prototype.getTitle = function () { return this._story.title; }; /** * Return the inventory object * @return {JSON} Dictionary with all the objects currently in inventory */ Dedalus.prototype.getInventory = function () { return this._story.inventory; }; /** * Return the current page id * @return {String} Id of the current page */ Dedalus.prototype.getCurrentPageId = function () { return this._story.currentPage; }; /** * Set the current page * @param {String} id Id of the page wanted as current page */ Dedalus.prototype.setCurrentPageId = function (id) { this._story.currentPage = id; }; /** * Return the current page object * @return {JSON} Page object */ Dedalus.prototype.getCurrentPage = function () { return this.getPage(this._story.currentPage); }; /** * Return the number of actions (everything that triggered a display action) * @return {Integer} Number of actions performed at the current moment */ Dedalus.prototype.getNumTotalActions = function () { return this._story.numTotalActions; }; /** * Return the number of actions (everything that triggered a display action) * since entering the current page * @return {Integer} Number of actions performed at the current moment in the * current page */ Dedalus.prototype.getNumActionsPerformedInPage = function () { return this._story.numActionsPerformedInPage; }; /** * Return the total number of pages shown so far * @return {Integer} Number of pages shown at the current moment */ Dedalus.prototype.getNumPagesTurned = function () { return this._story.numPagesTurned; }; /** * Return the total number of paragraphs shown so far * @return {Integer} Number of paragraphs shown at the current moment */ Dedalus.prototype.getNumParagraphsShown = function () { return this._story.numParagraphsShown; }; /** * Return the total number of paragraphs shown from the last page turn * @return {Integer} Number of paragraphs shown in the page at the current moment */ Dedalus.prototype.getNumParagraphsShownInPage = function () { return this._story.numParagraphsShownInPage; }; /** * Reset the page and paragraph counters */ Dedalus.prototype.resetCounters = function () { this._story.numPagesTurned = 0; this._story.numParagraphsShown = 0; this._story.numParagraphsShownInPage = 0; this._story.numTotalActions = 0; this._story.numActionsPerformedInPage = 0; }; /** * Undo the last issued action. The concrete implementation is delegated to * executeUndo() */ Dedalus.prototype.undo = function () { story = jQuery.extend({}, this.storyUndo); this._story = jQuery.extend({}, this._storyUndo); this.executeUndo(); }; /** * Save the current story running state. The concrete implementation is * delegated to executeSave */ Dedalus.prototype.save = function () { this.executeSave(story, this._story); }; /** * Restore the application to the last saved position. The concrete implementation * is delegated to executeRestore(). Execute the restore of if there is any * save available. The check of save avaibility is implementation specific. */ Dedalus.prototype.restore = function () { var savedData = this.getRestoreData(), savedStory = savedData[0], saved_Story = savedData[1]; if (this.saveAvailable()) { // Restores story and _story to the initial state and merges into them // the saved stated this._story = jQuery.extend(true, this.parseStory(this.options.domSource), saved_Story); story = jQuery.extend(true, this.generateEmptyStory(), savedStory); this.executeRestore(); } }; /** * Restart the execution of the story */ Dedalus.prototype.reset = function () { this._story = this.parseStory(this.options.domSource); story = this.generateEmptyStory(); this.executeReset(); }; /** * If the story defines a <beforeEveryThing> script, execute it */ Dedalus.prototype.executeBeforeEveryThing = function () { eval(this._story.beforeEveryThing); }; /** * If the story defines a <beforeEveryPageTurn> script, execute it */ Dedalus.prototype.executeBeforeEveryPageTurn = function () { eval(this._story.beforeEveryPageTurn); }; /** * If the story defines a <beforeEveryParagraphShown> script, execute it */ Dedalus.prototype.executeBeforeEveryParagraphShown = function () { eval(this._story.beforeEveryParagraphShown); }; /** * If the story defines a <afterEveryThing> script, execute it */ Dedalus.prototype.executeAfterEveryThing = function () { eval(this._story.afterEveryThing); }; /** * If the story defines a <afterEveryPageTurn> script, execute it */ Dedalus.prototype.executeAfterEveryPageTurn = function () { eval(this._story.afterEveryPageTurn); }; /** * If the story defines a <afterEveryParagraphShown> script, execute it */ Dedalus.prototype.executeAfterEveryParagraphShown = function () { eval(this._story.afterEveryParagraphShown); }; /* ** TO BE CUSTOMIZED BY IMPLEMENTATIONS ** */ /** * Implementation-specific display action * @param {doT} content dotT template function whose result must be displayed * @param {Bool} turnPage Whether to turn page before the display. Defaults to false */ Dedalus.prototype.executePrinting = function (content, turnPage) {}; /** * Implementation-specific undo action */ Dedalus.prototype.executeUndo = function () {}; /** * Implementation-specific save action * @param {JSON} story Dedalus story object to save * @param {JSON} _story Dedalus _story object to save */ Dedalus.prototype.executeSave = function (story, _story) {}; /** * Check if there is any save state available * @return {Bool} Whether there is any saved state available */ Dedalus.prototype.saveAvailable = function () {}; /** * Implementation-specific restore action */ Dedalus.prototype.executeRestore = function () {}; /** * Implementation-specific reset action */ Dedalus.prototype.executeReset = function () {}; /** * Return restored data * @return {Array} An array whose first element is the restored from save * Dedauls story object, and the second is Dedalus _story: * [story, _story] */ Dedalus.prototype.getRestoreData = function () {}; /** * Implementation specific action executed after the freezing of a state for * undoing purposes */ Dedalus.prototype.afterUndoSave = function () {}; /** * Implementation specific method to convert an interactive element (such as * "turn to" or "interact with") into a normal text. The process can be * restored by Dedalus.prototype.enable() * @param {jQUery} element Link to be disabled */ Dedalus.prototype.disable = function (element) {}; /** * Implementation specific method to revert a previously disabled interactive * element (see Dedalus.prototype.enable()) */ Dedalus.prototype.enable = function () {}; /** * Implementation specific action executed when the story comes to an end */ Dedalus.prototype.endGame = function () {}; /** * Get the content of a given jQuery element and returns is "as is", without * any encoding that may occur with the use of .html() or .text() * @param {jQuery} element jQuery element whose content has to be returned as * string * @return {String} String with the raw content of element */ Dedalus.getRawContent = function (element) { var contents = element.contents(), out = ''; contents.each(function () { out += this.nodeType === 3 ? this.nodeValue : this.outerHTML; }); return out; }; /* ** UTILTY FUNCTIONS ** */ /** * Generate a message dispatcher that implements the Publish/Subscribe pattern * @return {JSON} A message center object: * { * publish : Function to issue messages * subscribe : Function to subscribe to the message center * unsubscribe : Function to unsubscribe to the message center * } */ function makeMessageCenter () { // A dictionary of channel objects: // { // 'Channel name': { // 'Subscriber name': Function to be called when a message is // published to the channel // } // } var channels = {}; /** * Called with a functions, agrees to receive notifications when new * messages are issued to that channel * @param {String} channel Identifier of the channel * @param {String} subscriber Identifier of the subscriber * @param {Function} callback Function to be called when a new message * message is published to the channel */ function subscribe (channel, subscriber, callback) { if (!channels.hasOwnProperty(channel)) { channels[channel] = {}; } channels[channel][subscriber] = callback; } /** * Removes a subscriber from the channel * @param {String} channel Identifier of the channel to be removed from * @param {String} subscriber Identifier of the subscriber that wants to * be removed */ function unsubscribe (channel, subscriber) { if (!channels.hasOwnProperty(channel)) { channels[channel] = {}; } if (channels[channel].hasOwnProperty(subscriber)) { delete channels[channel][subscriber]; } } /** * Calls the function (passing message) defined by every subscriber that * subscribed the channel where the message is issued * @param {String} channel Identifier of the channel where the message * is published * @param {Object} message Object to pass to the function */ function publish (channel, message) { var subscriber; if (channels.hasOwnProperty(channel)) { for (subscriber in channels[channel]) { if (channels[channel].hasOwnProperty(subscriber)) { channels[channel][subscriber](message); } } } } return { publish : publish, subscribe : subscribe, unsubscribe : unsubscribe }; } }());