/** * FRISE (FRee Interactive Story Engine) * A light-weight engine for writing interactive fiction and games. * * Copyright 2022-2023 Christopher Pollett chris@pollett.org * * @license * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * * @file The JS code used to manage a FRISE game * @author Chris Pollett * @link https://www.frise.org/ * @copyright 2022 - 2023 */ /* * Global Variables */ /** * Contains {locale => {to_translate_id => translation}} for each * item in the game engine that needs to be localized. * There are not too many strings to translate. For a particular game you * could tack on to this tl variable after you load game.js in a script * tag before you call initGame. * @type {Object} */ var tl = { "en": { "init_slot_states_load" : "Load", "init_slot_states_save" : "Save", "restore_state_invalid_game" : "Does not appear to be a valid game save", "move_object_failed" : "moveObject(object_id, location) failed.\n" + "Either the object or location does not exist.\n" } }; /** * Locale to use when looking up strings to be output in a particular language. * We set the default here, but a particular game can override this in * its script tag before calling initGame() * @type {string} */ var locale = "en"; /** * Flag to set the text direction of the game to right-to-left. * (if false, the text direction is left-to-right) * @type {boolean} */ var is_right_to_left = false; /** * Global game object used to track one interactive story fiction game * @type {Game} */ var game; /** * Flag used to tell if current user is interacting on a mobile device or not * @type {boolean} */ var is_mobile = window.matchMedia("(max-width: 1000px)").matches; /** * Common Global Functions */ /** * Returns an Element object from the current document which has the provided * id. * @param {string} id of element trying to get an object for * @return {Element} desired element, if exists, else null */ function elt(id) { return document.getElementById(id); } /** * Returns a collection of objects for Element's that match the provided Element * name in the current document. * * @param {string} name of HTML/XML Element wanted from current document * @return {HTMLCollection} of matching Element's */ function tag(name) { return document.getElementsByTagName(name); } /** * Returns a list of Node's which match the CSS selector string provided. * * @param {string} a CSS selector string to match against current document * @return {NodeList} of matching Element's */ function sel(selector) { return document.querySelectorAll(selector); } /** * Returns a game object based on the element in the current document of the * provided id. * * @param {string} id of element in current document. * @return {Object} a game object. */ function xelt(id) { return makeGameObject(elt(id)); } /** * Returns an array of game objects based on the elements in the current * document that match the CSS selector passed to it. * * @param {string} a CSS selector for objects in the current document * @return {Array} of game objects based on the tags that matched the selector */ function xsel(selector) { let tag_objects = sel(selector); return makeGameObjects(tag_objects); } /** * Returns an array of game objects based on the elements in the current * document of the given tag name. * * @param {string} a name of a tag such as x-location or x-object. * @return {Array} of game objects based on the tags that matched the selector */ function xtag(name) { let tag_objects = tag(name); return makeGameObjects(tag_objects); } /** * Sleep time many milliseconds before continuing to execute whatever code was * executing. This function returns a Promise so needs to be executed with await * so that the code following the sleep statement will be used to resolve the * Promise * * @param {number} time time in milliseconds to sleep for * @return {Promise} a promise whose resolve callback is to be executed after * that many milliseconds. */ function sleep(time) { return new Promise(resolve => setTimeout(resolve, time)); } /** * Adds a link with link text message which when clicked will allow the * rendering of a Location to continue. * @param {string} message link text for the link that is pausing the rendering * of the current location. * @return {Promise} which will be resolved which the link is clicked. */ function clickProceed(message) { let game_content = elt("game-content"); game.tick++; game_content.innerHTML += `<a id='click-proceed${game.tick}' href=''>${message}</a>`; return new Promise(resolve => elt(`click-proceed${game.tick}`).addEventListener("click", resolve)); } /** * Creates from an HTMLCollection or Nodelist of x-object or x-location * elements an array of Location's or Object's suitable for a FRISE Game * object. Elements of the collection whose name aren't of the form x- * or which don't have id's are ignored. Otherwise, the id field of * the output Object or Location is set to the value of the x-object or * x-location's id. Details about how a suitable FRISE Game object is created * can be found @see makeGameObject() * * @param {HTMLCollection|Nodelist} tag_objects collection to be converted into * an array FRISE game objects or location objects. * @return {Array} an array of FRISE game or location objects. */ function makeGameObjects(tag_objects) { let game_objects = {}; for (const tag_object of tag_objects) { let game_object = makeGameObject(tag_object); if (game_object && game_object.id) { game_objects[game_object.id] = game_object; } } return game_objects; } /** * Upper cases first letter of a string * @param {string} str string to upper case the first letter of * @return {string} result of upper-casing */ function upperFirst(str) { if (!str) { return ""; } let upper_first = str.charAt(0).toUpperCase(); return upper_first + str.slice(1); } /** * Used as part of the closure of makeGameObject so that every game * object has an integer id. * @type {number} */ var object_counter = 0; /** * Used to convert a DOM Element dom_object to an Object or Location suitable * for a FRISE game. dom_object's whose tagName does not begin with x- * will result in null being returned. If the tagName is x-location, a * Location object will be returned, otherwise, a Javascript Object is returned. * The innerHTML of any subtag of an x-object or an * x-location beginning with x- becomes the value of a field in the resulting * object with the name of the tag less x-. For example, a DOM Object * representing the following HTML code: * <x-object id="bob"> * <x-name>Robert Smith</x-name> * <x-age>25</x-age> * </x-object> * will be processed to a Javascript Object * { * id: "bob", * name: "Robert Smith", * age: "25" * } * @param {Element} DOMElement to be convert into a FRISE game Object or * Location * @return {Object} the resulting FRISE game Object or Location or * null if the tagName of the DOMElement didn't begin with x- */ function makeGameObject(dom_object) { if (!dom_object || dom_object.tagName.substring(0, 2) != "X-") { return null; } let tag_name = dom_object.tagName.slice(2); let type = dom_object.getAttribute("type"); if (type) { type = upperFirst(type); } else if (tag_name && tag_name != "OBJECT") { type = upperFirst(tag_name.toLowerCase()); } else { type = "Object"; } let game_object; if (type == "Location") { game_object = new Location(); } else { game_object = {}; } if (dom_object.id) { game_object.id = dom_object.id; } else { game_object.id = "oid" + object_counter; object_counter++; } let has_present = false; let has_position = false; for (const child of dom_object.children) { let tag_name = child.tagName; if (tag_name == 'SCRIPT') { let type = child.getAttribute("type"); if (type == "text/action") { tag_name = "X-ACTION"; } else if (type == "text/default-action") { tag_name = "X-DEFAULT-ACTION"; } } if (tag_name.substring(0, 2) != "X-") { continue; } let attribute_name = tag_name.slice(2); if (attribute_name) { attribute_name = attribute_name.toLowerCase() if (attribute_name == 'present') { has_present = true; if (!game_object[attribute_name]) { game_object[attribute_name] = []; } let check = ""; let is_else = false; for(let check_attr of ['ck', 'check', 'else-ck', 'else-check', 'else']) { let tmp_check = child.getAttribute(check_attr); if (tmp_check) { check = tmp_check; if (['else-ck', 'else-check'].includes(check_attr)) { is_else = true; } break; } } let stage = child.getAttribute("stage"); if (!stage) { stage = ""; } game_object[attribute_name].push([check, stage, is_else, child.innerHTML]); } else { if (attribute_name == 'position') { has_position = true; } game_object[attribute_name] = child.innerHTML; } } } game_object.type = type; if (type == 'Location') { game_object.has_present = has_present; } else if (type == 'Object') { game_object.has_position = has_position; } return game_object; } /** * Used to change the display css property of the element of the provided id * to display_type if it doesn't already have that value, if it does, * then the display property is set to none. If display_type is not provided * then its value is assumed to be block. * * @param {string} id of HTML Element to toggle display property of * @param {string} display_type value to toggle between it and none. */ function toggleDisplay(id, display_type) { if (display_type === undefined) { display_type = "block"; } obj = elt(id); if (obj.style.display == display_type) { value = "none"; } else { value = display_type; } obj.style.display = value; if (value == "none") { obj.setAttribute('aria-hidden', true); } else { obj.setAttribute('aria-hidden', false); } } /** * Used to toggle the display or non-display of the main navigation bar * on the side of game screen */ function toggleMainNav() { let game_content = elt('game-content'); let nav_obj = elt('main-nav'); if ((!nav_obj.style.left && !nav_obj.style.right) || nav_obj.style.left == '0px' || nav_obj.style.right == '0px') { game_content.style.width = "calc(100% - 40px)"; if (is_right_to_left) { game_content.style.right = "55px"; nav_obj.style.right = '-300px'; } else { game_content.style.left = "55px"; nav_obj.style.left = '-300px'; } if (is_mobile) { nav_obj.style.width = "240px"; game_content.style.width = "calc(100% - 70px)"; } nav_obj.style.backgroundColor = 'white'; } else { if (is_right_to_left) { nav_obj.style.right = '0px'; game_content.style.right = "300px"; } else { nav_obj.style.left = '0px'; game_content.style.left = "300px"; } game_content.style.width = "calc(100% - 480px)"; if (is_mobile) { nav_obj.style.width = "100%"; if (is_right_to_left) { game_content.style.right = "100%"; } else { game_content.style.left = "100%"; } } nav_obj.style.backgroundColor = 'lightgray'; } } /** * Adds click event listeners to all anchor objects in a list * that have href targets beginning with #. Such a target * is to a location within the current game, so the click event callback * then calls game.takeTurn based on this url. * * @param {NodeList|HTMLCollection} anchors to add click listeners for * game take turn callbacks. */ function addListenersAnchors(anchors) { let call_toggle = false; if (arguments.length > 1) { if (arguments[1]) { call_toggle = true; } } for (const anchor of anchors) { let hash = anchor.getAttribute("href"); if (hash && hash[0] == "#") { anchor.addEventListener('click', (event) => { if (!anchor.classList.contains('disabled')) { game.takeTurn(hash); if (game.has_nav_bar && call_toggle) { toggleMainNav(); } } event.preventDefault(); if (window.location.hash) { delete window.location.hash; } }); } } } /** * Used to disable any volatile links which might cause * issues if clicked during rendering of the staging * portion of a presentation. For example, saves screen links * in either the main-nav panel/main-nav bar or in the * game content area. The current place where this is used * is at the start of rendering a location. A location * might have several x-present tags, some of which could * be drawn after a clickProceed, or a delay. As these * steps in rendering are not good "save places", at the * start of rendering a location, all save are disabled. * Only after the last drawable x-present for a location * has completed are they renabled. */ function disableVolatileLinks() { for (let location_id of game.volatileLinks()) { let volatile_links = sel(`[href~="#${location_id}"]`); for (const volatile_link of volatile_links) { volatile_link.classList.add('disabled'); } } } /** * Used to re-enable any disabled volatile links * in either the main-nav panel/main-nav bar or in the * game content area. The current place where this is used * is at the end of rendering a location. For why this is called, * @see disableVolatileLinks() */ function enableVolatileLinks() { for (let location_id of game.volatileLinks()) { let volatile_links = sel(`[href~="#${location_id}"]`); for (const volatile_link of volatile_links) { volatile_link.classList.remove('disabled'); } } } /** * Given a string which may contain Javascript string interpolation expressions, * i.e., ${some_expression}, produces a string where those expressions have * been replaced with their values. * * @param {string} text to have Javascript interpolation expressions replaced * with their values * @return {string} result of carrying out the replacement */ function interpolateVariables(text) { old_text = ""; while (old_text != text) { old_text = text; let interpolate_matches = text.match(/\$\{([^\}]+)\}/); if (interpolate_matches && interpolate_matches[1]) { let interpolate_var = interpolate_matches[1]; if (interpolate_var.replace(/\s+/, "") != "") { interpolate_var = interpolate_var.replaceAll(/\>\;?/g, ">"); interpolate_var = interpolate_var.replaceAll(/\<\;?/g, "<"); interpolate_var = interpolate_var.replaceAll(/\&\;?/g, "&"); if (interpolate_var) { let interpolate_value = eval(interpolate_var); text = text.replace(/\$\{([^\}]+)\}/, interpolate_value); } } } } return text; } /** * Returns the game object with the provided id. * @param {string} object_id to get game object for * @return {object} game object associated with object_id if it exists */ function obj(object_id) { return game.objects[object_id]; } /** * Returns the game location with the provided id. * @param {string} object_id to get game Location for * @return {Location} game location associated with object_id if it exists */ function loc(location_id) { return game.locations[location_id]; } /** * Returns the game object associated with the main-character. This * function is just an abbreviation for obj('main-character') * @return {object} game object associated with main-character */ function mc() { return game.objects['main-character']; } /** * Returns the location object associated with the main-character current * position. * @return {Location} associated with main-character position */ function here() { return game.locations[game.objects['main-character'].position]; } /** * Returns the Location object the player begins the game at */ function baseLoc() { return game.locations[game.base_location]; } /** * For use in default actions only!!! Returns whether the main-character is * in the Location of the default action. In all other cases returns false * @return {boolean} where the main-character is in the room of the current * default action */ function isHere() { return game['is_here']; } /** * Returns whether the main character has ever been to the location * given by location_id * * @param {string} location_id id of location checking if main character has * been to * @return {boolean} whther the main chracter has been there */ function hasVisited(location_id) { return (loc(location_id).visited > 0); } /** * Encapsulates one place that objects can be in a Game. */ class Location { /** * An array of [check_condition, staging, is_else, text_to_present] tuples * typically coming from the x-present-tag's in the HTML of a Location. * @type {Array} */ present = []; /** * Number of times main-character has visited a location * @type {int} */ visited = 0; /** * Used to display a description of a location to the game content * area. This description is based on the x-present tags that were * in the x-location tag from which the Location was parse. For * each such x-present tag in the order it was in the original HTML, * the ck/else-ck condition and staging is first evaluated * once/if the condition is satisfied, staging is processed (this may * contain a delay or a clickProceed call), then the HTML contents of the * tag are shown. In the case where the ck or else-ck evaluates to false * than the x-present tag's contents are omitted. In addition to the * usual HTML tags, an x-present tag can have x-speaker subtags. These * allow one to present text from a speaker in bubbles. An x-present tag * may also involve input tags to receive/update values for x-objects or * x-locations. */ async renderPresentation() { disableVolatileLinks(); let game_content = elt("game-content"); game_content.innerHTML = ""; game_content.scrollTop = 0; game_content.scrollLeft = 0; let check, staging, is_else, section_html; let check_result, proceed, pause; check_result = false; for (let section of this.present) { if (!section[3]) { continue; } [check, staging, is_else, section_html] = section; if (is_else && check_result) { continue; } [check_result, proceed, pause] = this.evaluateCheckConditionStaging(check, staging); let prepared_section = this.prepareSection(section_html); if (check_result) { if (proceed) { let old_inner_html = game_content.innerHTML; event = await clickProceed(proceed); event.preventDefault(); game_content.innerHTML = old_inner_html; } else if (pause) { await sleep(pause); } game_content.innerHTML += prepared_section; } } game_content.innerHTML += "<div class='footer-space'></div>"; this.prepareControls(); let anchors = sel("#game-content a, #game-content x-button"); addListenersAnchors(anchors); if (!game.volatileLinks().includes(mc().position)) { enableVolatileLinks(); } } /** * Prepares input, textareas, and select tags in the game so that they can * bind to game Object or Location fields by adding various Javascript * Event handlers. An input tag like * <input data-for="bob" name="name" > * binds with the name field of the bob game object (i.e., obj(bob).name). * In the case above, as the default type of an input tag is text, this * would produce a text field whose initial value is the current value * obj(bob).name. If the user changes the field, the value of the obj * changes with it. This set up binds input tags regardless of type, so it * can be used with other types such as range, email, color, etc. */ prepareControls() { const content_areas = ["main-nav", "game-content"]; for (const content_area of content_areas) { let content = elt(content_area); if (!content) { continue; } if (content_area == 'main-nav') { if (!content.hasOwnProperty("originalHTML")) { content.originalHTML = content.innerHTML; } content.innerHTML = interpolateVariables(content.originalHTML); game.initializeGameNavListeners(); } let control_types = ["input", "textarea", "select"]; for (const control_type of control_types) { let control_fields = content.querySelectorAll(control_type); for (const control_field of control_fields) { let target_object = null; let target_name = control_field.getAttribute("data-for"); if (typeof target_name != "undefined") { if (game.objects[target_name]) { target_object = game.objects[target_name]; } else if (game.locations[target_name]) { target_object = game.locations[target_name]; } if (target_object) { let target_field = control_field.getAttribute( "name"); let control_subtype = ''; if (target_field) { if (control_type == "input") { control_subtype = control_field.getAttribute("type"); if (control_subtype == 'radio') { if (control_field.value == target_object[target_field]) { control_field.checked = target_object[target_field]; } } else { control_field.value = target_object[target_field]; } } else if (target_object[target_field]) { /* if don't check target_object[target_field] not empty then select tags get extra blank option */ control_field.value = target_object[target_field]; } if(!control_field.disabled) { if (control_type == "select") { control_field.addEventListener("change", (evt) => { target_object[target_field] = control_field.value; }); } else if (control_subtype == "radio") { control_field.addEventListener("click", (evt) => { if (control_field.checked) { target_object[target_field] = control_field.value; } }); } else { control_field.addEventListener("input", (evt) => { target_object[target_field] = control_field.value; }); } } } } } } } } } /** * Evaluates the condition in a ck or else-ck attribute of an x-present tag. * * @param {string} condition contents from a ck, check, else-ck, * or else-check attribute. Conditions can be boolean conditions * on game variables. If an x-present tag did not have a ck attribute, * condition is null. * @param {string} staging contents from a stage attribute. * If no such attribute, this will be an empty string. * Such an attribute could have a sequence of * pause(some_millisecond); and clickProceed(some_string) commands * @return {Array} [check_result, proceed, pause] if the condition involved * a boolean expression, then check_result will hold the result of * the expression (so the caller then could prevent the the display of * an x-present tag if false), proceed is the link text (if any) for a * link for the first clickProceed (which is supposed to delay the * presentation of the x-present tag until after the user clicks the * link) if found (else ""), pause (if non zero) is the number of * milliseconds to sleep before presenting the x-present tag according to * the condition */ evaluateCheckConditionStaging(condition, staging) { let proceed = ""; let pause = 0; condition = (typeof condition == "string") ? condition : ""; let check_result = (condition.replace(/\s+/, "") != "") ? eval(condition) : true; if (typeof check_result != "boolean") { check_result = false; console.log(condition + " didn't evaluate to a boolean"); } let staging_remainder = staging; let old_staging = ""; while (check_result && old_staging != staging_remainder) { old_staging = staging_remainder; let click_pattern = /clickProceed\([\'\"]([^)]+)[\'\"]\);?/; let click_match = staging_remainder.match(click_pattern); if (click_match) { proceed = click_match[1]; break; } let pause_pattern = /pause\(([^)]+)\);?/; let pause_match = staging_remainder.match(pause_pattern); if (pause_match) { pause += parseInt(pause_match[1]); } staging_remainder = staging_remainder.replace(pause_pattern, ""); } return [check_result, proceed, pause]; } /** * A given Location contains one or more x-present tags which are * used when rendering that location to the game-content area. This * method takes the text from one such tag, interpolates any game * variables into it, and adds to any x-speaker tags in it the HTML * code to render that speaker bubble (adds img tag for speaker icon, etc). * * @param {string} section text to process before putting into the * game content area. * @return {string} resulting HTML text after interpolation and processing */ prepareSection(section) { let old_section = ""; let quote = `(?:(?:'([^']*)')|(?:"([^"]*)"))`; section = interpolateVariables(section); while (section != old_section) { old_section = section; let speaker_pattern = new RegExp( "\<x-speaker([^\>]*)name\s*\=\s*("+quote+")([^\>]*)>", 'i'); let expression_pattern = new RegExp( "expression\s*\=\s*("+quote+")", 'i'); let speaker_match = section.match(speaker_pattern); if (speaker_match) { let speaker_id = speaker_match[4]; let pre_name = (speaker_match[3]) ? speaker_match[3] : ""; let post_name = (speaker_match[5]) ? speaker_match[5] : ""; let rest_of_tag = pre_name + " " + post_name; let expression_match = rest_of_tag.match(expression_pattern); if (speaker_id && game.objects[speaker_id]) { let speaker = game.objects[speaker_id]; let name = speaker.name; if (expression_match && expression_match[3]) { name += " <b>(" + expression_match[3] + ")</b>"; } let icon = speaker.icon; let html_fragment = speaker_match[0]; let html_fragment_mod = html_fragment.replace(/name\s*\=/, "named="); if (icon) { section = section.replace(html_fragment, html_fragment_mod + "<figure><img src='" + speaker.icon + "' " + "loading='lazy' ></figure><div>" + name + "</div><hr>"); } } } } return section; } } /** * Class used to encapsulate an interactive story game. It has fields * to track the locations and objects in the game, the history of moves of * the game, and how many moves have been made. It has methods to * take a turn in such a game, to save state, load state, * restore prev/next state from history, render the state of such * a game. */ class Game { /** * A semi-unique identifier for this particular game to try to ensure * two different games hosted in the same folder don't collide in * sessionStorage. * @type {string} */ id; /** * Whether game page was just reloaded * @type {bool} */ reload; /** * Current date followed by a space followed by the current time of * the most recent game capture. Used in providing a description of * game saves. * @type {number} */ timestamp; /** * A counter that is incremented each time Javascript draws a new * clickProceed a tag. Each such tag is given an id, tick is used to ensure * these id's are unique. * @type {number} */ tick = 0; /** * Whether this particular game has a nav bar or not * @type {boolean} */ has_nav_bar; /** * List of all Game Object's managed by the FRISE script. An object * can be used to represent a thing such as a person, tool, piece of * clothing, letter, etc. In an HTML document, a game object is defined * using an x-object tag. * @type {Array<Object>} */ objects; /** * List of all game Location's managed by the FRISE script. A Location * can be used to represent a place the main character can go. This * could be standard locations in the game, as well as Locations * like a Save page, Inventory page, Status page, etc. * In an HTML document a game Location is defined using an x-location tag. * @type {Array<Location>} */ locations; /** * Used to maintain a stack (using Array push/pop) of Game State Objects * based on the turns the user has taken (the top of the stack corresponds * to the previous turn). A Game State Object is a serialized string: * { * timestamp: capture_time, * objects: array_of_game_objects_at_capture_time, * locations: array_of_game_locations_at_capture_time, * } * @type {Array} */ history; /** * Used to maintain a stack (using Array push/pop) of Game State Objects * based on the the number previous turn clicks the user has done. * I.e., when a user clicks previous turn, the current state is pushed onto * this array so that if the user then clicks next turn the current * state can be restored. * A Game State Object is a serialized string: * { * timestamp: capture_time, * objects: array_of_game_objects_at_capture_time, * locations: array_of_game_locations_at_capture_time, * } * @type {Array} */ future_history; /** * Id of first room main-character is in; * @type {String} */ base_location; /** * Is set to true just before a default action for a location the * main character is at is executed; otherwise, false * @type {Boolean} */ is_here; /** * List of id's of buttons and links to disable during the staging * phases of rendering a presentation or when viewing those locations. * @type {Array} */ volatile_links = ['saves', 'inventory']; /** * Sets up a game object with empty history, an initialized main navigation, * and with objects and locations parsed out of the current HTML file */ constructor() { let title_elt = tag('title')[0]; if (!title_elt) { title_elt = tag('x-game')[0]; } this.reload = false; //current let doc_length = 0; let middle_five = ""; if (title_elt) { doc_length = title_elt.innerHTML.length; if (doc_length > 8) { let half_length = Math.floor(doc_length/2); middle_five = title_elt.innerHTML.slice( half_length, half_length + 5); } } // a semi-unique code for this particular game this.id = encodeURI(middle_five + doc_length); this.initializeMainNavGameContentArea(); this.initializeObjectsLocations(); this.clearHistory(); } /** * Writes to console information about which objects and locations * might not be properly defined. */ debug() { let none = "none"; console.log("Game objects without position:"); for (let obj of Object.values(this.objects)) { if (!obj.has_position) { console.log(" " + obj.id); none = ""; } } if (none) { console.log(" " + none); } none = "none"; console.log("Game locations without x-present:"); for (loc of Object.values(this.locations)) { if (!loc.has_present) { console.log(" " +loc.id); none = ""; } } if (none) { console.log(" " + none); } return true; } /** * Used to reset the game to the condition at the start of a game */ reset() { sessionStorage.removeItem("current" + this.id); this.initializeMainNavGameContentArea(); this.initializeObjectsLocations(); this.clearHistory(); } /** * Sets up the main navigation bar and menu on the side of the screen * determined by the is_right_to_left variable. Sets up an initially empty * game content area which can be written to by calling a Location * object's renderPresentation. The main navigation consists of a hamburger * menu toggle button for the navigation as well as previous * and next history arrows at the top of screen. The rest of the main * navigation content is determined by the contents of the x-main-nav * tag in the HTML file for the game. If this tag is not present, the * game will not have a main navigation bar and menu. */ initializeMainNavGameContentArea() { let body_objs = tag("body"); if (body_objs[0] === undefined) { return; } let body_obj = body_objs[0]; let game_screen = elt('game-screen'); if (!game_screen) { body_obj.innerHTML = '<div id="game-screen"></div>' + body_obj.innerHTML; game_screen = elt('game-screen'); } let main_nav_objs = tag("x-main-nav"); if (typeof main_nav_objs[0] === "undefined") { game_screen.innerHTML = `<div id="game-content"></div>`; this.has_nav_bar = false; return; } this.has_nav_bar = true; let main_nav_obj = main_nav_objs[0]; let history_buttons; if (is_right_to_left) { history_buttons = `<button id="previous-history">→</button> <button id="next-history">←</button>`; } else { history_buttons = `<button id="previous-history">←</button> <button id="next-history">→</button>`; } game_screen.innerHTML = ` <div id="main-bar"> <button id="main-toggle" class="float-left"><span class="main-close">≡</button> </div> <div id="main-nav"> ${history_buttons} <div id="game-nav"> ${main_nav_obj.innerHTML} </div> </div> <div id="game-content"></div>`; this.initializeGameNavListeners(); } /** * Used to initialize the event listeners for the next/previous history * buttons. It also adds listeners to all the a tag and x-button tags * to process their href attributes before following any link is followed * to its target. * @see addListenersAnchors */ initializeGameNavListeners() { elt('main-toggle').onclick = (evt) => { toggleMainNav('main-nav', 0); }; elt('next-history').onclick = (evt) => { this.nextHistory(); } elt('previous-history').onclick = (evt) => { this.previousHistory(); } let anchors = sel('#game-nav a, #game-nav x-button'); addListenersAnchors(anchors, is_mobile); } /** * Checks if the game is being played on a mobile device. If not, this * method does nothing, If it is being played on a mobile device, * then this method sets up the viewport so that the HTML will * display properly. Also, in the case where the game is being played on a * mobile device, this method also sets it so the main nav bar on the side * of the screen is closed. */ initializeScreen() { let html = tag("html")[0]; if (is_right_to_left) { html.classList.add("rtl"); } if (!this.has_nav_bar) { html.classList.add("no-nav"); } if(!is_mobile) { return; } html.classList.add("mobile"); let head = tag("head")[0]; head.innerHTML += `<meta name="viewport" `+ `content="width=device-width, initial-scale=1.0" >` toggleMainNav(); } /** * For each object, if object.position is defined, then adds the object * to the location.item array of the Location whose id is * given by object.position . */ initializeObjectsLocations() { this.objects = xtag("x-object"); this.locations = xtag("x-location"); for (const oid in this.objects) { let object = this.objects[oid]; if (object.hasOwnProperty("position")) { let location_name = object.position; if (this.locations.hasOwnProperty(location_name)) { let location = this.locations[location_name] if (!location.hasOwnProperty("items")) { location.items = []; } location.items.push(object.id); } } } } /** * Creates a JSON encoded string representing the current state of * the game (all of the object and location states and where the main * character is). * * @return {string} JSON encoded current state of game. */ captureState() { let now = new Date(); let date = now.getFullYear() + '-' + (now.getMonth() + 1) + '-' + now.getDate(); let time = now.getHours() + ":" + now.getMinutes() + ":" + now.getSeconds(); game.timestamp = date + " " + time; return JSON.stringify({ timestamp: game.timestamp, base_location: game.base_location, objects: this.objects, locations: this.locations }); } /** * Sets the current state of the game (current settings for all objects, * locations, and main character position), based on the state given in * a JSON encode string representing a game state. * * @param {string} gave_save a JSON encoded state of the a FRISE game. */ restoreState(game_save) { let game_state = JSON.parse(game_save); if (!game_state || !game_state.timestamp || !game_state.objects || !game_state.locations) { alert(tl[locale]['restore_state_invalid_game']); return false; } this.timestamp = game_state.timestamp; this.base_location = game_state.base_location; /* during development, changing an object or location's text might not be viewable on a reload unless we copy some fields of the reparsed html file into a save game object. */ let old_objects = this.objects; this.objects = game_state.objects; for (const field in old_objects) { if (!this.objects.hasOwnProperty(field)) { /* we assume our game never deletes objects or locations, so if we find an object in old_objects (presumably it's coming from a more recently parsed HTML file) that was not in the saved state, we copy it over. */ this.objects[field] = old_objects[field]; } else { if (old_objects.hasOwnProperty('action')) { this.objects['action'] = old_objects['action']; } else if (this.objects.hasOwnProperty('action')) { delete this.objects['action']; } } } let old_locations = this.locations; let locations = game_state.locations; let location; this.locations = {}; for (const location_name in old_locations) { if (!locations.hasOwnProperty(location_name)) { location = old_locations[location_name]; } else { let location_object = locations[location_name]; location = new Location(); for (const field in location_object) { location[field] = location_object[field]; if (field == 'present' || field == 'action' || field == 'default-action') { if (!old_locations[ location_name].hasOwnProperty(field)) { delete location[field]; } else { location[field] = old_locations[location_name][field]; } } } } this.locations[location_name] = location; } return true; } /** * Deletes the game state capture history for the game. After this * calling this method, the game's next and previous arrow buttons * won't do anything until new turns have occurred. */ clearHistory() { this.history = []; this.future_history = []; let next_history_elt = elt('next-history'); if (next_history_elt) { next_history_elt.disabled = true; elt('previous-history').disabled = true; } } /** * Called when the left arrow button on the main nav page is * clicked to go back one turn in the game history. Pushes the current * game state to the future_history game state array, then pops the most * recent game state from the history game state array and sets it as * the current state. */ previousHistory() { if (this.history.length == 0) { return; } let current_state = this.captureState(); this.future_history.push(current_state); let previous_game_state = this.history.pop(); this.restoreState(previous_game_state); sessionStorage["current" + this.id] = previous_game_state; this.describeMainCharacterLocation(); if (this.history.length == 0) { elt('previous-history').disabled = true; } else { elt('previous-history').disabled = false; } elt('next-history').disabled = false; } /** * Called when the right arrow button on the main nav page is * clicked to go forward one turn in the game history (assuming the user had * clicked previous at least once). Pushes the current game state * to the history game state array, then pops the game state from the * future_history game state array and sets it as the current state. */ nextHistory() { if (this.future_history.length == 0) { return; } let current_state = this.captureState(); this.history.push(current_state); let next_game_state = this.future_history.pop(); this.restoreState(next_game_state); sessionStorage["current" + this.id] = next_game_state; this.describeMainCharacterLocation(); if (this.future_history.length == 0) { elt('next-history').disabled = true; } else { elt('next-history').disabled = false; } elt('previous-history').disabled = false; } /** * Initializes the save slots for the saves location page of a game. * This involves looking at session storage and determining which slots * have games already saved to them, and for those slots, determining also * what time the game was saved. */ initSlotStates() { let saves_location = game.locations['saves']; for (const field in saves_location) { let slot_matches = field.match(/^slot(\d+)/); if (slot_matches && slot_matches[1]) { let slot_number = parseInt(slot_matches[1]); let game_save = sessionStorage.getItem("slot" + game.id + slot_number); if (game_save) { let game_state = JSON.parse(game_save); saves_location["slot" + slot_number] = tl[locale]['init_slot_states_load']; saves_location["delete" + slot_number] = ""; saves_location["filled" + slot_number] = 'filled'; saves_location["filename" + slot_number] = game_state.timestamp; } else { saves_location["slot" + slot_number] = tl[locale]['init_slot_states_save']; saves_location["filled" + slot_number] = 'not-filled'; saves_location["delete" + slot_number] = "disabled"; saves_location["filename" + slot_number] = '...'; } } } } /** * Saves the current game state to a sessionStorage save slot if that * slot if empty; otherwise, if the slot has data in it, then sets * the current game state to the state stored at that slot. When saving, * this method also records the timestamp of the save time to the * game's saves location. * * @param {number} slot_number */ saveLoadSlot(slot_number) { slot_number = parseInt(slot_number); let saves_location = game.locations['saves']; let game_state = sessionStorage.getItem("slot" + game.id + slot_number); if (game_state) { this.clearHistory(); sessionStorage["current" + game.id] = game_state; this.restoreState(game_state); } else { let save_state = this.captureState(); game_state = this.history[this.history.length - 1]; this.restoreState(game_state); saves_location['filename' + slot_number] = this.timestamp; sessionStorage.setItem("slot" + game.id + slot_number, game_state); this.restoreState(save_state); this.evaluateAction(saves_location['default-action']); } } /** * Deletes any game data from sessionStorage at location * "slot" + slot_number, updates the game's saves location to reflect the * change. * * @param {number} slot_number which save game to delete. Games are stored * at a sessionStorage field "slot" + slot_number where it is intended * (but not enforced) that the slot_number be an integer. */ deleteSlot(slot_number) { slot_number = parseInt(slot_number); let saves_location = game.locations['saves']; sessionStorage.removeItem("slot" + game.id + slot_number); saves_location['filled' + slot_number] = "not-filled"; saves_location['delete' + slot_number] = "disabled"; saves_location['slot' + slot_number] = tl[locale]['init_slot_states_save']; saves_location['filename' + slot_number] = "..."; this.evaluateAction(saves_location['default-action']); } /** * Deletes all game saves from sessionStorage */ deleteSlotAll() { let i = 1; let saves_location = game.locations['saves']; while (saves_location.hasOwnProperty('filename' + i)) { this.deleteSlot(i); i++; } this.evaluateAction(saves_location['default-action']); } /** * Launches a file picker to allow the user to select a file * containing a saved game state, then tries to load the current game * from this file. */ load() { let file_load = elt('file-load'); if (!file_load) { file_load = document.createElement("input"); file_load.type = "file"; file_load.id = 'file-load'; file_load.style.display = 'none'; file_load.addEventListener('change', (event) => { let to_read = file_load.files[0]; let file_reader = new FileReader(); file_reader.readAsText(to_read, 'UTF-8') file_reader.addEventListener('load', (load_event) => { let game_state = load_event.target.result; this.clearHistory(); sessionStorage["current" + this.id] = game_state; this.restoreState(game_state); game.describeMainCharacterLocation(); }); }); } file_load.click(); } /** * Creates a downloadable save file for the current game state. */ save() { let game_state = this.history[this.history.length - 1]; let file = new Blob([game_state], {type: "plain/text"}); let link = document.createElement("a"); link.href = URL.createObjectURL(file); link.download = "game_save.txt"; link.click(); } /** * Computes one turn of the current game based on the provided url hash * fragment. A url hash fragment is the part of the url after a # symbol. * In non-game HTML, #fragment is traditionally used to indicate the browser * should show the page as if it had been scrolled to where the element * with id attribute fragment is. In a FRISE game, a fragment has * the form #action_1_name;action_2_name;...;action_n_name;next_location_id * Such a fragment when processed by takeTurn will cause the Javascript in * x-action tags with id's action_1_name, action_2_name,...,action_n_name * to be invoked in turn. Then the main-character object is moved to * location next_location_id. If the fragment, only consists of * 1 item, i.e., is of the form, #next_location_id, then this method * just moves the main-character to next_location_id. * After carrying out the action and moving the main-character, * takeTurn updates the game state history and future_history * accordingly. Then for each object and each location, * if the object/location, has an x-default-action tag, this default action * is executed. Finally, the Location of the main-character is presented * (its renderPresentation is called). * takeTurn supports two special case action #previous and #next * which move one step back or forward (if possible) in the Game state * history. * @param {string} hash url fragment ot use when computing one turn of the * current game. */ takeTurn(hash) { if (this.has_nav_bar) { if (hash == "#previous") { this.previousHistory(); return; } else if (hash == "#next") { this.nextHistory(); return; } } let new_game_state; if (sessionStorage["current" + game.id]) { new_game_state = sessionStorage["current" + game.id]; } if (!this.moveMainCharacter(hash)) { return; } this.future_history = []; if (this.has_nav_bar) { elt('next-history').disabled = true; } if (sessionStorage["current" + game.id]) { this.history.push(new_game_state); } this.evaluateDefaultActions(this.objects); this.evaluateDefaultActions(this.locations); sessionStorage["current" + game.id] = this.captureState(); this.describeMainCharacterLocation(); game.reload = false; if (this.has_nav_bar) { if (this.history.length == 0) { elt('previous-history').disabled = true; } else { elt('previous-history').disabled = false; } } } /** * For each game Object and each game Location in x_entities evaluate the * Javascript (if it exists) of its default action (from its * x-default-action tag). * * @param {Array} of game Object's or Location's */ evaluateDefaultActions(x_entities) { for (const object_name in x_entities) { let game_entity = x_entities[object_name]; if (mc().position == object_name && game_entity instanceof Location) { game['is_here'] = true; } else { game['is_here'] = false; } if (game_entity && game_entity['default-action']) { this.evaluateAction(game_entity['default-action']); } } } /** * Moves a game Object to a new game Location. If the object had a * previous location, then also deletes the object from there. * * @param {string} object_id of game Object to move * @param {string} destination_id of game Location to move it to */ moveObject(object_id, destination_id) { let move_object = this.objects[object_id]; if (!move_object || !this.locations[destination_id]) { alert(tl[locale]['move_object_failed'] + "\nmoveObject('" + object_id + "', '" + destination_id + "')"); return false; } if (move_object.hasOwnProperty("position")) { let old_position = move_object.position; let old_location = this.locations[old_position]; old_location.items = old_location.items.filter((value) => { return value != object_id; }); } move_object.position = destination_id; let new_location = this.locations[destination_id]; if (!new_location.items) { new_location.items = []; } new_location.items.push(object_id); return true; } /** * Moves the main character according to the provided url fragment. * * @param {string} hash a url fragment as described above */ moveMainCharacter(hash) { if (!hash || hash <= 1) { return true; } hash = hash.substring(1); let hash_parts = hash.split(";"); let destination = hash_parts.pop(); for (const hash_part of hash_parts) { let hash_matches = hash_part.match(/([^\(]+)(\(([^\)]+)\))?\s*/); let action = elt(hash_matches[1]); let args = []; if (typeof hash_matches[3] !== 'undefined') { args = hash_matches[3].split(","); } if (action && action.tagName == 'X-ACTION' || (action.tagName == 'SCRIPT' && action.getAttribute('type') == 'text/action')) { let code = action.innerHTML; if (code) { this.evaluateAction(code, args); } } } if (destination == "exit") { this.describeMainCharacterLocation(); return false; } if (destination == "previous") { this.previousHistory(); return false; } if (destination == "next") { this.nextHistory(); return false; } let mc = obj('main-character'); if (mc.position != mc.destination && mc.position != 'saves') { mc.old_position = mc.position; } this.moveObject('main-character', destination); this.locations[mc.position].visited++; return true; } /** * Given a string holding pre-Javascript code from an x-action tag, * evaluates the code. If this function is passed additional arguments * then an args array is set up that can be used as a closure variable for * this eval call. * * @param {string} Javascript code. */ evaluateAction(code) { var args = []; if (arguments.length > 1) { if (arguments[1]) { args = arguments[1]; } } eval(code); } /** * Used to present the location that the Main Character is currently at. */ describeMainCharacterLocation() { let main_character = this.objects['main-character']; let position = main_character.position; let location = this.locations[position]; location.renderPresentation(); } /** * Return the array of link ids which should be disable while performing * the staging of a presentation * * @return {Array} */ volatileLinks() { return this.volatile_links; } } /** * Module initialization function used to set up the game object corresponding * to the current HTML document. If there is a current game state in * sessionStorage it is used to initialize the game state, otherwise, * the game state is based on the start of the game. After this state is * set up, the current location is drawn to the game content area. */ async function initGame() { game = new Game(); let use_session = false; if (sessionStorage["current" + game.id]) { use_session = true; game.restoreState(sessionStorage["current" + game.id]); game.reload = true; } else { game.base_location = game.objects['main-character'].position; } game.takeTurn(""); game.clearHistory(); game.initializeScreen(); /* Any game specific customizations are assumed to be in the function localInitGame if it exists */ if (typeof localInitGame == 'function') { localInitGame(); } }