/**
* 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.innerHTML = "<span tabindex='0'>" +anchor.innerHTML +
"</span>";
let handle = (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;
}
};
anchor.addEventListener('keydown', (event) => {
if (event.code == 'Enter' || event.code == 'Space') {
handle(event);
}
});
anchor.addEventListener('click', (event) => handle(event));
}
}
}
/**
* 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;
}
/**
* A data-ck attribute on any tag other than an x-present tag can
* contain a Javascript boolean expression to control the display
* or non-display of that element. This is similar to a ck attribute
* of an x-present tag. This method evalutes data-ck expressions for
* each tag in the text in its section argument and adds a class="none"
* attribute to that tag if it evaluates to false (causing it not to
* display). The string after these substitutions is returned.
*
* @param {string} section of text to check for data-ck attributes and
* for which to carry out the above described substitutions
* @return {string} after substitutions have been carried out.
*/
function evaluateDataChecks(section)
{
let quote = `(?:(?:'([^']*)')|(?:"([^"]*)"))`;
let old_section = "";
while (section != old_section) {
old_section = section;
let data_ck_pattern = new RegExp(
"\<(?:[^\>]+)(data\-ck\s*\=\s*(" + quote + "))(?:[^\>]*)\>",
'i');
let data_ck_match = section.match(data_ck_pattern);
if (!data_ck_match) {
continue;
}
let condition = (typeof data_ck_match[3] == 'string') ?
data_ck_match[3] : data_ck_match[4];
if (typeof condition == 'string') {
let check_result = (condition.replace(/\s+/, "") != "") ?
eval(condition) : true;
if (check_result) {
section = section.replace(data_ck_match[1], " ");
} else {
section = section.replace(data_ck_match[1],
" class='none' ");
}
}
}
return section;
}
/**
* 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);
content.innerHTML = evaluateDataChecks(content.innerHTML);
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 = evaluateDataChecks(section);
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();
/*
Any game specific customizations are assumed to be in the function
localInitGame if it exists
*/
if (typeof localInitGame == 'function') {
localInitGame();
}
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();
}