/**
* FRISE (FRee Interactive Story Engine)
* A light-weight engine for writing interactive fiction and games.
*
* Copyright 2022-2024 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 - 2024
*/
/*
* 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",
"slide_titles" : "Slide Titles"
}
};
/**
* 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;
/**
* Number of x-include tags left to load before game can run
* @type {int}
*/
var includes_to_load = 0;
/**
* Used as part of the closure of makeGameObject so that every game
* object has an integer id.
* @type {number}
*/
var object_counter = 0;
/**
* 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.
* @param {string} any html that should appear after the proceed link
* @return {Promise} which will be resolved which the link is clicked.
*/
function clickProceed(message, rest = "")
{
let game_content = elt("game-content");
game.tick++;
game_content.innerHTML +=
`<a id='click-proceed${game.tick}' href=''>${message}</a>${rest}`;
return new Promise(resolve =>
elt(`click-proceed${game.tick}`).addEventListener("click", resolve));
}
/**
* Given a rectangle within an Image object of a DollSlot doll_part
* specified by a point x,y and a width and a height. Scales it to a
* corresponding rectangle in the DollSlot coordinates (coordinates
* used to specify where it is within a Doll).
*
* @param {int} x coordinate of the image rectangle
* @param {int} y coordinate of the image rectangle
* @param {int} width of the image rectangle
* @param {int} height of the image rectangle
* @param {DollSlot} doll_part with respect to which the coordinate
* transformation is done
* @param {Array<int>} of length 4 containing output [x, y, width, height]
*/
function imageCoordinates(x, y, width, height, doll_part)
{
let scale_x = doll_part.image.width/parseInt(doll_part.width);
let scale_y = doll_part.image.height/parseInt(doll_part.height);
let part_x = parseInt(doll_part.x ?? 0);
let part_y = parseInt(doll_part.y ?? 0);
let out_x = Math.floor(scale_x * (x - part_x));
let out_y = Math.floor(scale_y * (y - part_y));
let out_width = Math.floor(scale_x * width);
let out_height = Math.floor(scale_y * height);
return [out_x, out_y, out_width, out_height];
}
/**
* Given an array of arrays [Object, property_name1, property_name2, ...]
* Parses the property names to int's in the given object. If the
* this results in Not a Number (NaN) then sets the property to the
* provided default value
*
* @param Array<Array<Object, String...>>
* @param int default_value
*/
function parseObjectsInts(objects_ints, default_value = 0)
{
for (const object_ints of objects_ints) {
let obj = object_ints[0];
for (field_name of object_ints[1]) {
let convert = obj[field_name];
convert = parseInt(convert);
obj[field_name] = (isNaN(convert)) ? default_value : convert;
}
}
}
/**
* 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);
}
/**
* Given two rectangles: [x1, y1, width1, height1] and [x2, y2, width2, height2]
* Computes an intersection [x_out, y_out, width_out, height_out] of the
* rectangles if the rectangles intersect and outputs false otherwise.
*
* @param {Array<int>} rect1 4-tuple [x1, y1, width1, height1] of first
* rectangle
* @param {Array<int>} rect2 4-tuple [x1, y1, width1, height1] of second
* rectangle
* @return {Array<int>|boolean} 4-tuple [x_out, y_out, width_out, height_out] of
* intersection rectangle or false otherwise
*/
function intersectRectangles(rect1, rect2)
{
[x1, y1, width1, height1] = rect1.map((x) => {return 1*x}); //make int
[x2, y2, width2, height2] = rect2.map((x) => {return 1*x});
let x_out = Math.max(x1, x2);
let y_out = Math.max(y1, y2);
let width_out = Math.min(x1 + width1, x2 + width2) - x_out;
let height_out = Math.min(y1 + height1, y2 + height2) - y_out;
if (isNaN(x_out) || isNaN(y_out) || isNaN(width_out) ||
isNaN(height_out) || width_out <= 0 || height_out <= 0) {
return false;
}
return [x_out, y_out, width_out, height_out];
}
/**
* 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 if (type == "Doll") {
game_object = new Doll();
} else if (type == "Slots") {
type = "DollSlots";
game_object = new DollSlots();
} else if (type == "Slot") {
type = "DollSlot";
game_object = new DollSlot();
} else {
game_object = {};
}
if (dom_object.id) {
game_object.id = dom_object.id;
} else {
game_object.id = "oid" + object_counter;
object_counter++;
}
let style = dom_object.getAttribute('style');
if (typeof style != 'undefined') {
game_object.style = style;
}
let class_list = dom_object.getAttribute('class');
if (typeof class_list != 'undefined') {
game_object["class"] = class_list;
}
let has = {
'color' : false,
'icon' : false,
'height' : false,
'position' : false,
'present' : false,
'slots' : false,
'width' : false
};
let script_tags = {
"text/action" : "X-ACTION",
"text/default-action" : "X-DEFAULT-ACTION",
"text/click-listener" : "X-CLICK-LISTENER",
"text/collision-listener" : "X-COLLISION-LISTENER",
"text/update-listener" : "X-UPDATE-LISTENER",
};
for (const child of dom_object.children) {
let tag_name = child.tagName;
if (tag_name == 'SCRIPT') {
let script_type = child.getAttribute("type");
if (typeof script_tags[script_type] != 'undefined') {
tag_name = script_tags[script_type];
}
}
if (tag_name.substring(0, 2) != "X-") {
continue;
}
let attribute_name = tag_name.slice(2);
if (attribute_name) {
attribute_name = attribute_name.toLowerCase();
has[attribute_name] = true;
if (attribute_name == 'present') {
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 (type == 'Doll' && attribute_name == 'slots') {
game_object[attribute_name] = makeGameObject(child);
} else if (type == 'DollSlots' && attribute_name == 'slot') {
let slot = makeGameObject(child);
if (slot.type == 'DollSlot') {
game_object.push(slot);
}
} else {
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'];
} else if (type == 'Doll') {
game_object.has_height = has['height'];
game_object.has_icon = has['icon'];
game_object.has_color = has['color'];
game_object.has_width = has['width'];
if (!has['slots']) {
game_object.slots = new DollSlots();
}
game_object.init();
}
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 = "block")
{
let 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 url = anchor.getAttribute("href");
if (url && url[0] == "#") {
let hash = url;
anchor.innerHTML = "<span tabindex='0'>" +anchor.innerHTML +
"</span>";
let handle = async (event) => {
let transition = anchor.getAttribute('data-transition');
let duration = anchor.getAttribute('data-duration');
let iterations = anchor.getAttribute('data-iterations');
let origin = anchor.getAttribute('data-transform-origin');
let preset_origins = { 'top-left-origin' : "0 0",
'top-mid-origin' : "50% 0",
'top-right-origin' : "100% 0",
'bottom-left-origin' : "0 50%",
'bottom-mid-origin' : "50% 100%",
'bottom-right-origin' : "100% 100%",
'mid-left-origin' : "0 50%",
'mid-right-origin' : "100% 50%",
'mid-mid-origin' : "50% 50%",
'center-origin' : "50% 50%"
};
if (transition) {
let game_content = elt('game-content');
game_content.classList.add(transition);
duration = (duration) ? parseInt(duration) : 2000;
iterations = (iterations) ? parseInt(iterations) : 1;
origin ??= "50% 50%";
if (typeof preset_origins[origin] != 'undefined') {
origin = preset_origins[origin];
}
game_content.style.animationDuration = duration + "ms";
game_content.style.animationIterationCount = iterations;
game_content.style.transformOrigin = origin;
await sleep(duration);
elt('game-content').classList.remove(transition);
}
let is_slides = sel("x-slides")[0] ? true : false;
if (is_slides) {
window.location = hash;
game.takeTurn(hash);
return;
}
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));
} else if (!url || url.length < 11 ||
url.substring(0, 11).toLowerCase() != 'javascript:') {
anchor.innerHTML = "<span tabindex='0'>" + anchor.innerHTML +
"</span>";
anchor.addEventListener('click', (event) => {
window.location = url;
});
}
}
}
/**
* 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');
section = section.replace(/\&/g, "&");
let data_ck_match = section.match(data_ck_pattern);
if (!data_ck_match || section.includes("cli" + data_ck_match[1])) {
//don't want above to accidentally also match onclick=
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 doll (paperdoll) with the provided id.
* @param {string} object_id to get game doll for
* @return {Location} game doll associated with object_id if it exists
*/
function doll(doll_id)
{
return game.dolls[doll_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 = "";
if (typeof this["style"] != 'undefined' && this["style"]) {
game_content.setAttribute('style', this["style"]);
}
if (typeof this["class"] != 'undefined' && this["class"]) {
game_content.setAttribute('class', this["class"]);
}
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);
if (check_result) {
let prepared_section = this.prepareSection(section_html);
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);
}
/*
A given prepared section might involve
<x-stage></x-stage> tags to simulate a typewriter effect,
or delays or clicks to continue.
The code below is used to present these tags.
*/
let rest_section = prepared_section;
let final_section = "";
let xstage_open = "<x-stage";
let xstage_close = "</x-stage";
let open_pos, end_pos, greater_pos;
end_pos = rest_section.lastIndexOf(xstage_close);
if (end_pos > 0) {
final_section = rest_section.substring(end_pos +
xstage_close.length);
greater_pos = final_section.search(">");
if (greater_pos >= 0) {
final_section = final_section.substring(greater_pos + 1);
} else {
greater_pos = final_section.length;
}
rest_section = rest_section.substring(0, end_pos +
xstage_close.length + greater_pos);
}
let inner_html = game_content.innerHTML;
while ((open_pos = rest_section.search(xstage_open)) != -1) {
let animation_speed = 50;
let click_matches = false;
let is_short_tag = false;
inner_html += rest_section.substring(0, open_pos);
rest_section = rest_section.substring(open_pos +
xstage_open.length);
greater_pos = rest_section.search(">");
if (rest_section.charAt(greater_pos - 1) == "/") {
is_short_tag = true;
}
if (greater_pos >= 0) {
let tag_contents = rest_section.substring(0,
greater_pos + 1);
let delay_matches = tag_contents.match(
/delay\=(\"(\d+)\"|\'(\d+)\')/);
if (delay_matches) {
animation_speed =
(typeof delay_matches[2] == 'undefined') ?
delay_matches[3] : delay_matches[2];
animation_speed = parseInt(animation_speed);
}
click_matches = tag_contents.match(
/delay\=(\"click\"|\'click\')/);
rest_section = rest_section.substring(greater_pos + 1);
} else {
rest_section = "";
}
end_pos = rest_section.search(xstage_close);
open_pos = rest_section.search(xstage_open);
let stage_text = "";
if (is_short_tag || end_pos == -1 || (open_pos > 0 &&
end_pos > open_pos)) {
end_pos = 0;
// assume tag was not for delayed typing, just for delay
} else {
stage_text = rest_section.substring(0, end_pos);
rest_section = rest_section.substring(end_pos +
xstage_close.length);
greater_pos = rest_section.search(">");
if (greater_pos >= 0) {
rest_section =
rest_section.substring(greater_pos + 1);
}
}
if (click_matches) {
game_content.innerHTML = inner_html;
event = await clickProceed(stage_text);
event.preventDefault();
event.stopPropagation();
game_content.innerHTML = inner_html;
} else {
let old_length = inner_html.length;
inner_html += stage_text;
game.stop_stage = false;
let game_content = elt('game-content');
game_content.setAttribute('tabindex', 0);
game_content.addEventListener('click', () => {
game.stop_stage = true;
});
game_content.addEventListener('keydown', () => {
game.stop_stage = true;
});
await sleep(animation_speed);
for (let i = old_length; i <= old_length + end_pos;
i++) {
game_content.innerHTML = inner_html.substring(0, i)
await sleep(animation_speed);
if (game.stop_stage) {
game_content.onclick = null;
break;
}
}
}
}
game_content.innerHTML = inner_html + rest_section +
final_section;
}
}
// footer used so a game location page doesn't get squished
if (!sel("x-slides")[0]) {
game_content.innerHTML += "<div class='footer-space'></div>";
}
this.prepareControls();
if (this['screen-footer']) {
let footer = elt('screen-footer');
if (!footer) {
footer = document.createElement('div');
footer.id = 'screen-footer';
elt('game-screen').appendChild(footer);
}
footer.innerHTML = this['screen-footer'];
}
let anchors = sel("#game-screen a, #game-screen x-button");
addListenersAnchors(anchors);
this.renderDollHouses();
if (typeof sessionStorage['show_footer'] != 'undefined') {
if (!sessionStorage['show_footer'] ||
sessionStorage['show_footer'] == 'false') {
let slide_footer =
sel('#game-screen .slide-footer')[0];
slide_footer.style.display = 'none';
}
}
if (!game.volatileLinks().includes(mc().position)) {
enableVolatileLinks();
}
}
/**
* For each Dollhouse in the current main-nav and game-content
* areas of the HTML browser window draw the Dollhouses contents to a
* canvas tag associated with it. Then sets up any key, click, and
* collision listeners associated with the Dollhouse to handle events
* on that canvas.
*/
renderDollHouses()
{
for (const screen_id of ["main-nav", "game-content"]) {
let screen_part = elt(screen_id);
if (!screen_part) {
continue;
}
let part_width = screen_part.clientWidth;
let part_height = screen_part.clientHeight;
let dom_dollhouses = sel(`#${screen_id} x-dollhouse`);
for (const dom_dollhouse of dom_dollhouses) {
let dollhouse = makeGameObject(dom_dollhouse);
if (!dollhouse['doll-id']) {
continue;
}
let doll_obj = doll(dollhouse['doll-id']);
if (!doll_obj) {
continue;
}
let width = dollhouse.width ?? part_width;
let height = dollhouse.height ?? part_height;
let source_x = dollhouse['source-x'] ?? (doll_obj['source-x'] ??
0);
dollhouse['source-x'] = source_x;
let source_y = dollhouse['source-y'] ?? (doll_obj['source-y'] ??
0);
dollhouse['source-y'] = source_y;
let source_width = dollhouse['source-width'] ??
(doll_obj['source-width'] ?? doll_obj.width);
dollhouse['source-width'] = source_width;
let source_height = dollhouse['source-height'] ??
(doll_obj['source-height'] ?? doll_obj.height);
dollhouse['source-height'] = source_height;
let canvas = document.createElement('canvas');
let dom_class = dom_dollhouse.getAttribute('class');
dom_class = (dom_class) ? `block ${dom_class}` : 'block';
let dom_style = dom_dollhouse.getAttribute('style');
dom_style ??= '';
canvas.setAttribute('class', dom_class);
canvas.setAttribute('style', dom_style);
canvas.setAttribute('width', width);
canvas.setAttribute('height', height);
canvas.setAttribute('data-source-x', source_x);
canvas.setAttribute('data-source-y', source_y);
canvas.setAttribute('data-source-width', source_width);
canvas.setAttribute('data-source-height', source_height);
let is_navigable = (typeof dollhouse.navigable != 'undefined' &&
(dollhouse.navigable === true ||
dollhouse.navigable === "true"));
if (is_navigable) {
let player_slot = doll_obj.getPlayerSlot(dollhouse);
player_slot.init(doll_obj);
dollhouse.player_slot = player_slot;
}
dollhouse.doll = doll_obj;
dollhouse.canvas = canvas;
dom_dollhouse.parentNode.insertBefore(canvas,
dom_dollhouse.nextSibling);
doll_obj.render(canvas);
if (is_navigable ||
doll_obj.has_collision_listeners ||
doll_obj.has_click_listeners ||
doll_obj.has_update_listeners) {
this.addDollHouseListeners(dollhouse);
}
}
}
}
/**
* Sets up the canvas element listeners associated with a Dollhouse.
* Possible
*
* @param {Dollhouse} whose canvas will add listeners to
*/
addDollHouseListeners(dollhouse)
{
let canvas = dollhouse.canvas;
let doll_obj = dollhouse.doll;
let player_slot = doll_obj.getPlayerSlot(dollhouse);
dollhouse.player_slot = player_slot;
if (dollhouse.initialized) {
return;
}
if ((dollhouse.navigable === true ||
dollhouse.navigable === "true")) {
parseObjectsInts([ [player_slot, ["x", "y", "width", "height"]],
[doll_obj, ["width", "height"]], [dollhouse, ["source-x",
"source-y", "source-width", "source-height",
'padding-top', 'padding-left', 'padding-bottom',
'padding-right']] ]);
dollhouse['source-width'] = (dollhouse['source-width'] > 0) ?
dollhouse['source-width'] : doll_obj.width;
dollhouse['source-height'] = (dollhouse['source-height'] > 0) ?
dollhouse['source-height'] : doll_obj.height;
let player_half_width = Math.floor(player_slot.width/2);
let player_half_height = Math.floor(player_slot.height/2);
let step_x = dollhouse['player-step-x'] ??
(dollhouse['player-step'] ?? 1);
step_x = parseInt(step_x);
let step_y = dollhouse['player-step-y'] ??
(dollhouse['player-step'] ?? 1);
step_y = parseInt(step_y);
let scroll_step_x = 0;
let scroll_step_y = 0;
let x_min = dollhouse['source-x'] + player_half_width;
let y_min = dollhouse['source-y'] + player_half_height;
let x_max = dollhouse['source-x'] + dollhouse['source-width'] -
player_half_width;
let y_max = dollhouse['source-y'] + dollhouse['source-height'] -
player_half_height;
if (dollhouse['scrollable'] && dollhouse['scrollable'] == "true") {
scroll_step_x = step_x;
scroll_step_y = step_y;
x_min = player_half_width;
x_max = doll_obj.width - player_half_width;
y_min = player_half_height;
y_max = doll_obj.height - player_half_height;
}
x_min += dollhouse['padding-left'];
x_max -= dollhouse['padding-right'];
y_min += dollhouse['padding-top'];
y_max -= dollhouse['padding-bottom'];
canvas.setAttribute('tabindex', 0);
canvas.focus();
canvas.addEventListener('keydown', event => {
if (player_slot.old_x &&
dollhouse.has_collision_listeners) {
return;
}
let change = false;
let old_values = [player_slot.x, player_slot.y,
dollhouse['source-x'], dollhouse['source-y'],
player_slot.width, player_slot.height
];
let x_pos = player_slot.x + player_half_width;
let y_pos = player_slot.y + player_half_height;
switch(event.code) {
case 'KeyW':
case 'ArrowUp':
if (y_pos > y_min) {
change = true;
dollhouse['source-y'] -= scroll_step_y;
player_slot.y -= step_y;
}
break;
case 'KeyS':
case 'ArrowDown':
if (y_pos < y_max) {
change = true;
dollhouse['source-y'] += scroll_step_y;
player_slot.y += step_y;
}
break;
case 'KeyA':
case 'ArrowLeft':
if (x_pos > x_min) {
change = true;
dollhouse['source-x'] -= scroll_step_x;
player_slot.x -= step_x;
}
break;
case 'KeyD':
case 'ArrowRight':
if (x_pos < x_max) {
change = true;
dollhouse['source-x'] += scroll_step_x;
player_slot.x += step_x;
}
break;
}
if (change) {
[player_slot.old_x, player_slot.old_y,
player_slot.old_source_x, player_slot.old_source_y,
player_slot.old_width, player_slot.old_height] =
old_values;
}
});
}
if (doll_obj.has_click_listeners) {
canvas.addEventListener("click", (event) => {
if (doll_obj['click-listener']) {
eval(doll_obj['click-listener']);
}
if (doll_obj.slots.has_click_listeners) {
let rect = canvas.getBoundingClientRect();
let x = event.clientX - rect.left;
let y = event.clientY - rect.top;
let source_x = dollhouse['source-x'] ?? 0;
let source_y = dollhouse['source-y'] ?? 0;
let source_width = dollhouse['source-width'] ??
doll_obj.width;
let source_height = dollhouse['source-height'] ??
doll_obj.height;
x = Math.floor((source_width * x / canvas.width) + source_x);
y = Math.floor((source_height * y / canvas.height) +
source_y);
for (const slot of doll_obj.slots.items) {
if (typeof slot != 'undefined' &&
slot['click-listener']) {
parseObjectsInts([[slot,
["x", "y", "width", "height"]]]);
if (slot.x <= x && x <= slot.x + slot.width &&
slot.y <= y && y <= slot.y + slot.height) {
eval(slot['click-listener']);
doll_obj.redraw = true;
}
}
}
}
});
}
if (dollhouse['navigable'] || doll_obj.has_update_listeners ||
doll_obj.has_collision_listeners ||
doll_obj.has_click_listeners) {
let refresh = (dollhouse.refresh) ? parseInt(dollhouse.refresh):
game.dollhouse_refresh_rate;
game.dollhouse_update_ids[game.dollhouse_update_ids.length] =
setInterval((event) => {
if (doll_obj['update-listener']) {
eval(doll_obj['update-listener']);
doll_obj.redraw = true;
}
for (const slot of doll_obj.slots.items) {
if (typeof slot != 'undefined' &&
slot['update-listener']) {
eval(slot['update-listener']);
doll_obj.redraw = true;
}
}
if (player_slot && player_slot.old_x) {
doll_obj.redraw = true;
}
if (doll_obj.has_collision_listeners) {
doll_obj.checkCollisions(dollhouse);
}
if (player_slot && player_slot.old_x) {
delete player_slot.old_x;
delete player_slot.old_y;
delete player_slot.old_source_x;
delete player_slot.old_source_y;
delete player_slot.old_width;
delete player_slot.old_height;
}
if (doll_obj.redraw) {
let source_x = dollhouse['source-x'] ?? 0;
canvas.setAttribute('data-source-x', source_x);
let source_y = dollhouse['source-y'] ?? 0;
canvas.setAttribute('data-source-y', source_y);
doll_obj.render(canvas);
doll_obj.redraw = false;
}
}, refresh);
}
dollhouse.initialized = true;
}
/**
* 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, typing] 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) is 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]);
}
}
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;
}
}
/**
* Encapsulates one paperdoll that might be drawn or animated to
* a canvas in the Game. A Doll has a width and height and either
* an image or background color. It also has a number of DollSlot
* subrectangles, on which other images or solid colors may be drawn.
* DollSlots can be animated if they responds to update events, can collide
* if respond to collision events, or can be clicked on if they respond to
* click events. A Doll can be navigable if it has a player dollslot
* and scrollable if it is vaigable and its dimension are large than its
* viewable area.
*/
class Doll
{
/**
* Whether the current Doll or any of its DollSlot objects respond to
* click events
* @type {boolean}
*/
has_click_listeners = false;
/**
* Whether the current Doll or any of its DollSlot objects have an update
* listener specified to be called according to the update time function
* @type {boolean}
*/
has_update_listeners = false;
/**
* Whether the current Doll or any of its DollSlot objects respond to
* collision between DollSlot events
* @type {boolean}
*/
has_collision_listeners = false;
/**
* Number of images in DollSlot object on this Doll that
* have yet to load
* @type {int}
*/
num_loading;
/**
* If the Doll is navigable, then this holds the index of the PlayerSlot
* in the array this.slots.items of DollSlot objects
* @type {int|boolean}
*/
player_index = false;
/**
* An array of canvas objects on which this Doll has been requested to
* render, but can't yet (say because relies on images which are still
* loading)
* @type {Array<Canvas>}
*/
render_requests;
/**
* Is true if the Doll is currently being drawn in some other thread
* @type {boolean}
*/
rendering;
/**
* Checks for any collisions between DollSlot objects on the current Doll
* that have collision listeners specified. If there are then the
* listeners are called with an event containing info about the two
* DollSlots involved and the Dollhouse on which the Doll will be rendered.
*
* @param {Dollhouse} dollhouse on which the current Doll is to be rendered
*/
checkCollisions(dollhouse)
{
if (this['collision-listener']) {
eval(this['collision-listener']);
}
let items = this.slots.items;
let len = items.length;
if (len == 1) {
return;
}
for (let i = 0; i < len; i++) {
for (let j = i + 1; j < len; j++) {
let s1 = items[i];
let s2 = items[j];
if (typeof s1 == 'undefined' ||
typeof s2 == 'undefined' ||
(!s1['collision-listener'] && !s2['collision-listener'])) {
continue;
}
let rect = intersectRectangles(
[s1.x, s1.y, s1.width, s1.height],
[s2.x, s2.y, s2.width, s2.height]);
if (rect) {
var event = {
'a' : s1,
'b' : s2,
'dollhouse' : dollhouse
};
if (s1['collision-listener']) {
eval(s1['collision-listener']);
}
if (s2['collision-listener']) {
eval(s2['collision-listener']);
}
}
}
}
}
/**
* Returns the DollSlot used to manage the player's coordinates and
* icon image within the current Doll (provided the Doll is navigable).
* If the player DollSlot doesn't exist an attempt is made to create it.
* If a Dollhouse is provided this is used in the attempt to create the
* player slot.
*
* @param {DollHouse?} dollhouse that may be used to help create the player
* DollSlot
* @return {DollSlot} the player DollSlot
*/
getPlayerSlot(dollhouse = null)
{
let player_slot;
let original_dh = dollhouse;
dollhouse ??= {'player-width': 50, 'player-height': 50,
'player-x': -1, 'player-x' : -1};
if (this.player_index === false) {
player_slot = new DollSlot();
player_slot.type = 'DollSlot';
this.setPlayerSlot(player_slot);
}
player_slot = this.slots.items[this.player_index];
if (dollhouse) {
parseObjectsInts([[dollhouse, ['player-width', 'player-height',
'player-x', 'player-x']]]);
}
player_slot.width ??= dollhouse['player-width'];
player_slot.height ??= dollhouse['player-height'];
player_slot.x ??= (dollhouse['player-x'] >= 0) ? dollhouse['player-x'] -
Math.floor(player_slot.width/2) :
Math.floor((this.width - player_slot.width)/2);
player_slot.y ??= (dollhouse['player-y'] >= 0) ?
dollhouse['player-y'] - Math.floor(player_slot.height/2) :
Math.floor((this.height - player_slot.height)/2);
if (typeof mc().icon !== 'undefined') {
player_slot.icon = mc().icon;
} else {
player_slot.color = (dollhouse['player-color']) ?
dollhouse['player-color'] : 'blue';
}
if (original_dh) {
player_slot.init(this);
}
this.setPlayerSlot(player_slot);
return player_slot;
}
/**
* Used to set up the Doll. This involves loading all images related to
* doll, and initializing the has_click_listeners, has_collision_listeners,
* has_update_listeners booleant
*/
init()
{
this.num_loading = 0;
this.render_requests = [];
this.rendering = false;
if (this.icon) {
this.image = this.loadImage(this.icon);
}
this.slots.init(this);
let listener_has_map = {
'click-listener' : 'has_click_listeners',
'collision-listener' : 'has_collision_listeners',
'update-listener' : 'has_update_listeners'
};
for (const listener in listener_has_map) {
let has_listener = listener_has_map[listener];
if (this[listener] || this.slots[has_listener]) {
this[has_listener] = true;
}
}
}
/**
* Initiates loading of the image at URL into an Image object and returns
* this object. Adds one to num_loading property of this Doll,
* so know Doll is tracking one more image to be loaded.
* Sets up a callback such that if all the images that have been requested
* for this Doll have loaded, and there have been requests to render
* this doll, then the Doll is rendered to each canvas requested.
*
* @param {string} URL of image to load
* @return {HTMLImageElement} image to be loaded
*/
loadImage(url)
{
let image = new Image();
image.src = url;
this.num_loading++;
image.onload = () => {
this.num_loading--;
if (this.num_loading <= 0) {
this.num_loading = 0;
} else {
return;
}
if (this.render_requests.length > 0 && !this.rendering) {
this.rendering = true;
for (const canvas of this.render_requests) {
this.render(canvas);
}
this.render_requests = [];
this.rendering = false;
}
}
return image;
}
/**
* If the passed slot is a DollSlot adds it to slots and returns true;
* otherwise, does nothing and returns false
* @param {DollSlot} slot to insert
* @return {boolean} whether the item was added or not
*/
push(slot)
{
return this.slots.push(slot);
}
/**
* Draws the current Doll to the provided canvas view. Note given the
* Doll's coordinate system only some portion of the Doll may be
* visible. If not all of the images for this Doll have been downloaded
* a render request is made instead.
*
* @param {HTMLCanvasElement} canvas to draw Doll onto
*/
render(canvas)
{
if (!canvas) {
return;
}
if (this.num_loading > 0) {
this.render_requests.push(canvas);
return;
}
const context = canvas.getContext("2d");
let source_x = parseInt(canvas.getAttribute('data-source-x'));
let source_y = parseInt(canvas.getAttribute('data-source-y'));
let source_width = parseInt(canvas.getAttribute('data-source-width'));
let source_height = parseInt(canvas.getAttribute('data-source-height'));
let view_width = parseInt(canvas.getAttribute('width'));
let view_height = parseInt(canvas.getAttribute('height'));
if (is_mobile) {
let max_mobile_width = window.screen.width - 40;
let max_mobile_height = view_height * max_mobile_width / view_width;
if (view_width > max_mobile_width) {
view_width = max_mobile_width;
view_height = max_mobile_height;
canvas.width = view_width;
canvas.height = view_height;
}
}
let color = this.color ?? 'white';
context.clearRect(0, 0, view_width, view_height);
let scale_x = view_width/source_width;
let scale_y = view_height/source_height;
if (this.image) {
let rect = intersectRectangles(
[source_x, source_y, source_width, source_height],
[0, 0, source_width, source_height]);
let coords = imageCoordinates(rect[0], rect[1],
rect[2], rect[3], this);
let dest_rect = [Math.floor(scale_x * (rect[0] - source_x)),
Math.floor(scale_y * (rect[1] - source_y)),
Math.floor(scale_x * rect[2]),
Math.floor(scale_y * rect[3])
];
context.drawImage(this.image, coords[0], coords[1], coords[2],
coords[3], dest_rect[0], dest_rect[1], dest_rect[2],
dest_rect[3]);
} else if (this.color) {
context.fillStyle = this.color;
context.fillRect(0, 0, view_width, view_height);
}
let doll_slots = this.slots.items;
if (!doll_slots) {
return;
}
for (const doll_slot of doll_slots) {
if (typeof doll_slot == 'undefined') {
continue;
}
let rect = intersectRectangles(
[source_x, source_y, source_width, source_height],
[doll_slot.x, doll_slot.y, doll_slot.width, doll_slot.height]);
if (rect) {
let dest_rect = [Math.floor(scale_x * (rect[0] - source_x)),
Math.floor(scale_y * (rect[1] - source_y)),
Math.floor(scale_x * rect[2]),
Math.floor(scale_y * rect[3])
];
if (doll_slot.image) {
let coords = imageCoordinates(rect[0], rect[1],
rect[2], rect[3], doll_slot, true);
context.drawImage(doll_slot.image,
coords[0], coords[1],
coords[2], coords[3], dest_rect[0], dest_rect[1],
dest_rect[2], dest_rect[3]);
} else {
context.fillStyle = doll_slot.color;
context.fillRect(dest_rect[0], dest_rect[1],
dest_rect[2], dest_rect[3]);
}
}
}
}
/**
* Sets the DollSlot object that corresponds to the person playing the
* game to player_slot. This object holds player info when a Doll is
* navigable
*
* @param {DollSlot} player_slot a doll slot with player's rectangle info
* on the doll, and icon or color to draw for player
*/
setPlayerSlot(player_slot)
{
if (this.player_index === false) {
this.player_index = this.push(player_slot);
} else {
this.slots.items[this.player_index] = player_slot;
}
}
}
/**
* Encapsulates the image slots a Doll might have
*/
class DollSlots
{
/**
* Whether any of the DollSlot object managed by this DollSlots has
* a click listener. Such a click listener is called if a the
* canvas used to draw a Doll is clicked and that click is within the
* rectangle of the listening DollSlot.
* @type {boolean}
*/
has_click_listeners = false;
/**
* Whether any of the DollSlot object managed by this DollSlots has
* a collision listener. Such a listener is called if the rectangle
* of a listening DollSlot intersects with another DollSlot in this
* DollSlots.
* @type {boolean}
*/
has_collision_listeners = false;
/**
* Whether any of the DollSlot object managed by this DollSlots has
* an update listener a timer is used to call such listeners periodically
* @type {boolean}
*/
has_update_listeners = false;
/**
* Array of DollSlot objects managed by this DollSlots.
* @type {Array}
*/
items = [];
/**
* If not null, Doll on which this DollSlot lives.
* @type {Doll?}
*/
parent;
/**
* Returns the DollSlot at index i if it exists else return null
* @param {int} DollSlot index to get
* @return {DollSlot?} the desired DollSlot if present
*/
get(i)
{
if (typeof this.items[i] != 'undefined') {
return this.items[i];
}
return null;
}
/**
* Calls init on each of the DollSlots in items. Initializes the
* has_click_listeners, has_collision_listeners, and has_update_listeners
* property based on checking for the corresponding listeners in
* each DollSlot iterated over.
*
* @param {Doll?} parent Doll used to help initialize each Doll (used
* for Image loading (Doll needs to keep track of how many of its
* DollSlots still need to be loaded))
*/
init(parent)
{
let listener_has_map = {
'click-listener' : 'has_click_listeners',
'collision-listener' : 'has_collision_listeners',
'update-listener' : 'has_update_listeners'
};
for (const doll_slot of this.items) {
if (doll_slot.type == 'DollSlot') {
doll_slot.init(parent);
for (const listener in listener_has_map) {
let has_listener = listener_has_map[listener];
if (doll_slot[listener]) {
this[has_listener] = true;
}
}
}
}
}
/**
* If the passed slot is a DollSlot adds it to items Array at index i
* and return true; otherwise, does nothing and returns false
* @param {int} i index in items array to insert DollSlot
* @param {DollSlot} slot to insert
* @return {boolean} whether the item was added or not
*/
set(i, slot)
{
if (typeof slot['type'] != 'undefined' && slot['type'] == 'DollSlot') {
this.items[i] = slot;
return i;
} else {
return false;
}
}
/**
* If the passed slot is a DollSlot adds it to items Array and returns true;
* otherwise, does nothing and returns false
* @param {DollSlot} slot to insert
* @return {boolean} whether the item was added or not
*/
push(slot)
{
return this.set(this.items.length, slot);
}
/**
* Removes and delete the DollSlot at location index from this DollSlots
* items array of DollSlot objects
* @param {int} index of DollSlot to remove
*/
remove(index)
{
if (typeof this.items[i] != 'undefined') {
delete this.items[index];
return true;
}
return false;
}
/**
* Removes the DollSlot of the given slot_id name from this DollSlots object
*
* @param {string} slot_id id property of the DollSlot to remove
* @return {boolean} true if a DollSlot with the given id is found and
* removed, false otherwise
*/
removeById(slot_id)
{
for (const i = 0; i < this.items.length; i++) {
if (this.items[i] && this.items[i].id == slot_id) {
delete this.items[i];
return true;
}
}
return false;
}
}
/**
* Encapsulates a single image slot for use in a Doll
*/
class DollSlot
{
/**
* The filename or object to be used by this slot in a paper Doll
* @type {}
*/
icon = "";
/**
* The image to be used by this slot in a paper Doll
* @type {Image}
*/
image = null;
/**
* The height of the slot in a paper Doll
* @type {int}
*/
height;
/**
* The width of the slot in a paper Doll
* @type {int}
*/
width;
/**
* The x coordinate within the overall Doll for this DollSlot
* @type {int}
*/
x;
/**
* The y coordinate within the overall Doll for this DollSlot
* @type {int}
*/
y;
/**
* Initializes this DollSlot within its parent Doll, parent_doll.
* This involves loading any Image associated with this DollSlot
* or discarding any image, if the DollSlot is going to be presented
* as a solid color.
*/
init(parent_doll)
{
if (this.icon) {
this.image = parent_doll.loadImage(this.icon);
} else if (this.image) {
delete this.image;
}
}
}
/**
* 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
{
/**
* If there are any dollhouses currently being drawn which are updateable
* this gives the default refresh rate in milliseconds
* @type {int}
*/
dollhouse_refresh_rate = 50;
/**
* ids of keyboard and setTimeouts that are currently updating
* dollhouses (if any) in the the location being presented
* @type {Array<int>}
*/
dollhouse_update_ids = [];
/**
* 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 {boolean}
*/
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;
/**
*
*
* @type {Array<Doll>}
*/
dolls;
/**
* 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.initializeObjectsLocationsDolls();
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 = "";
}
}
none = "none";
console.log("Game dolls missing required attributes:");
for (const doll_obj of Object.values(this.dolls)) {
if (!doll_obj.has_icon && !doll_obj.has_color) {
console.log(" " + doll_obj.icon + " has no icon or color");
none = "";
}
if (!doll.has_height) {
console.log(" " + doll_obj.height + " has no height");
none = "";
}
if (!doll.has_width) {
console.log(" " + doll_obj.width + " has no width");
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.initializeObjectsLocationsDolls();
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" tabindex="0">
</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");
}
let is_slides = sel("x-slides")[0] ? true : false;
if (is_slides) {
html.classList.add("slides");
}
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" >`;
if (this.has_nav_bar) {
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. Sets up the dolls Array of paperdolls
* used in the game (so their images start loading).
*/
initializeObjectsLocationsDolls()
{
this.objects = xtag("x-object");
this.locations = xtag("x-location");
this.dolls = xtag('x-doll');
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);
}
if (typeof object.original_position == 'undefined') {
object.original_position = object.position;
}
}
}
for (const lid in this.locations) {
let location = this.locations[lid];
if (!location.hasOwnProperty("items")) {
location.items = [];
}
if (typeof location.original_items == 'undefined') {
location.original_items = location.items;
}
}
}
/**
* 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 = localStorage.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 localStorage 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 = localStorage.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;
localStorage.setItem("slot" + game.id + slot_number, game_state);
this.restoreState(save_state);
this.evaluateAction(saves_location['default-action']);
}
}
/**
* Deletes any game data from localStorage at location
* "slot" + slot_number.
*
* @param {number} slot_number which save game to delete. Games are stored
* at a localStorage field "slot" + slot_number where it is intended
* (but not enforced) that the slot_number be an integer.
*/
deleteSlotData(slot_number)
{
slot_number = parseInt(slot_number);
let saves_location = game.locations['saves'];
localStorage.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] = "...";
}
/**
* Deletes any game data from localStorage 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 localStorage field "slot" + slot_number where it is intended
* (but not enforced) that the slot_number be an integer.
*/
deleteSlot(slot_number)
{
this.deleteSlotData(slot_number);
let saves_location = game.locations['saves'];
this.evaluateAction(saves_location['default-action']);
}
/**
* Deletes all game saves from sessionStorage
*/
deleteSlotAll()
{
let i = 0;
let saves_location = game.locations['saves'];
while (saves_location.hasOwnProperty('filename' + i)) {
this.deleteSlotData(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)
{
let new_game_state;
let is_slides = sel("x-slides")[0] ? true : false;
if (!is_slides) {
if (this.has_nav_bar) {
if (hash == "#previous") {
this.previousHistory();
return;
} else if (hash == "#next") {
this.nextHistory();
return;
}
}
if (sessionStorage["current" + game.id]) {
new_game_state = sessionStorage["current" + game.id];
}
}
if (!this.moveMainCharacter(hash)) {
return;
}
if (!is_slides) {
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();
if (!is_slides) {
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(/\s*\;\s*/);
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;
}
if (this.dollhouse_update_ids.length > 0) {
for (const update_id of this.dollhouse_update_ids) {
clearTimeout(update_id);
}
this.dollhouse_update_ids = [];
}
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. It first loads any x-included game fragments
* before calling @see finishInitGame to perform the rest of the game
* initialization process
*/
async function initGame()
{
loadIncludes(finishInitGame);
}
/**
* This module initialization is called to initialize FRISE when it is being
* used to show a slide presentation. It first loads any x-included slideshow
* fragments before calling @see finishInitSlides to perform the rest of the
* game initialization process
*/
async function initSlides()
{
loadIncludes(finishInitSlides);
}
/**
* Function used to load any x-included game fragments into this game.
* This function is run before the rest of the game's initialization is
* done in @see finishInitGame or finishInitSlides
*
* @param {Function} callback called to complete game initialization after
* the included game fragments are laoded. This is typically either
* finishInitGame or finishInitSlides
*/
async function loadIncludes(callback)
{
let includes = sel("x-include");
includes_to_load = includes.length;
if (includes_to_load == 0) {
return callback();
}
for (const include_node of includes) {
if (include_node.nextSibling.nodeName == 'X-LOADED') {
includes_to_load--;
continue;
}
let url = include_node.textContent.trim();
fetch(url).then(response => response.text()).then(
data => {
let loaded_node = document.createElement('x-loaded');
loaded_node.innerHTML = data;
include_node.parentNode.insertBefore(loaded_node,
include_node.nextSibling);
includes_to_load --;
if (includes_to_load <= 0) {
if (window.location.search == "?view-source") {
let encodeEntities = (text) => {
let textArea = document.createElement('textarea');
textArea.innerText = text;
return textArea.innerHTML;
}
let source = "<!doctype html>\n" +
document.documentElement.outerHTML;
source = encodeEntities(source);
let body = sel('body')[0];
body.innerHTML = `<pre><code>${source}</pre></code>`;
} else {
callback();
}
}
}
);
}
if (includes_to_load <= 0) {
if (window.location.search == "?view-source") {
alert("<!DOCTYPE html>\n" + document.documentElement.outerHTML);
} else {
callback();
}
}
}
/**
* This function is called after all images for Doll's have
* loaded to finish initializing the Frise Game. To do this, 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 finishInitGame()
{
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;
let is_slides = sel("x-slides")[0] ? true : false;
if (sessionStorage["current" + game.id] && !is_slides) {
use_session = true;
game.restoreState(sessionStorage["current" + game.id]);
game.reload = true;
} else {
game.base_location = game.objects['main-character'].position;
}
if (is_slides) {
window.addEventListener('hashchange', (event) => {
game.takeTurn(window.location.hash);
})
}
game.takeTurn("");
game.clearHistory();
game.initializeScreen();
}
/**
* If the current game is a slideshow presentation
* this function is called after all images for Doll's have
* loaded to finish initializing the slides for the presentation. This
* sets up the forward, backward, table of conents, all slides on a single
* page, and reset buttons as well as keyboard listeners to go this through
* slides
*/
async function finishInitSlides()
{
let slides = sel('x-slides')[0];
let home = sel(`link[rel=prev], meta[name=parent],
meta[property=parent]`)[0] ?? "";
if (home) {
home = home.getAttribute('content') ??
home.getAttribute('href');
if (home) {
home = `<x-button href='${home}'>🏠</x-button>`;
}
}
let slides_in_transition = slides.getAttribute("in-transition");
let slides_out_transition = slides.getAttribute("out-transition");
let slides_duration = slides.getAttribute("duration");
slides_duration = (slides_duration) ? slides_duration : "500";
let slides_iterations = slides.getAttribute("iteration");
let slides_origin = slides.getAttribute("origin");
let slides_children = slides.children;
if (!slides_children) {
return;
}
let slide_titles = sel('x-slides x-slide h1:first-of-type');
let x_game = sel('x-game')[0];
if (!x_game) {
x_game = document.createElement('x-game');
let body = sel('body')[0];
if (!body) {
return;
}
body.appendChild(x_game);
}
let i = 1;
let num_slides = 0;
let start_slide = "";
let all_slides = "<x-present><div class='all'>";
let title_location = `<x-location id="(titles)"><x-present>
<h1>${tl['en']['slide_titles']}</h1><ol style="font-size:130%;">`;
let game_contents = `<x-object id="main-character">
<x-position>(1)</x-position></x-object>`;
for (const slide of slides_children) {
if (slide.tagName == 'X-SLIDE') {
num_slides++;
}
}
for (const slide_title of slide_titles) {
title_location += `<li><a href='#(${i})'
>${slide_title.textContent}</a></li>`;
i++;
}
let title_slide_footer = ` <span class="big-p">(</span>
<x-button href="#(1)">⟳</x-button>
<x-button class="slides-titles" href="#(1)">-/${num_slides}</x-button>
<x-button href="#(all)">≡</x-button>
<span class="big-p">)</span> `;
title_location += `</ol></x-present><x-screen-footer
><div class='slide-footer center'
>${title_slide_footer}</div></x-screen-footer></x-location>`;
game_contents += title_location;
i = 1;
let slide_footer, all_slide_footer;
for (const slide of slides_children) {
if (slide.tagName == 'X-SLIDE') {
let in_transition = slide.getAttribute('in-transition');
in_transition ??= (slides_in_transition ?? "");
let out_transition = slide.getAttribute('out-transition');
out_transition ?? (out_transition ?? slides_out_transition);
let all_class_attribute = (slide.className) ?
` class='slide ${slide.className}' `: ` class='slide' `;
let class_attribute = (slide.className) ?
` class='slide ${slide.className} ${in_transition}' `:
` class='slide ${in_transition}' `;
let styles = slide.getAttribute('style');
let style_attribute = (styles) ? ` style='${styles}' ` : "";
let duration = slide.getAttribute('duration');
duration = (duration) ? duration : slides_duration;
let iterations = slide.getAttribute('iterations');
iterations = (iterations) ? iterations : slides_iterations;
let origin = slide.getAttribute('origin');
origin = (origin) ? origin : slides_origin;
if (out_transition) {
out_transition += ` data-transition="${out_transition}" `;
} else {
out_transition = "";
}
if (duration) {
out_transition += ` data-duration="${duration}" `;
}
if (iterations) {
out_transition += ` data-iterations="${iterations}" `;
}
if (origin) {
out_transition += ` data-transform-origin="${origin}" `;
}
let slide_contents = `<div ${class_attribute} ${style_attribute}>
${slide.innerHTML}</div>`;
let current_slide = `<x-present>${slide_contents}`;
let all_slide_contents = `<div ${all_class_attribute}
${style_attribute}>${slide.innerHTML}</div>`;
if (i > 1) {
slide_footer = `<x-button class="slides-previous"
href="#(${i - 1})" ${out_transition}>←</x-button>`;
} else {
slide_footer = `<x-button class="hidden">←</x-button>`
}
slide_footer += ` <span class="big-p">(</span>
<x-button class="slides-restart" href="#(1)">⟳</x-button>
${home} <x-button class="slides-titles" href="#(titles)"
>${i}/${num_slides}</x-button>
<x-button class="all-slides" href="#(all)">≡</x-button>
<span class="big-p">)</span>`;
if (i < num_slides) {
slide_footer += `<x-button class="slides-next"
href="#(${i + 1})" ${out_transition}>→</x-button>`
} else {
slide_footer += `<x-button class="hidden">→</x-button>`
}
all_slides += all_slide_contents;
if (i < num_slides) {
all_slides +=`<div class="page-break"><hr></div>`;
}
current_slide += `</x-present>`;
game_contents += `<x-location id="(${i})">
${current_slide}<x-screen-footer>
<div class='slide-footer center'
>${slide_footer}</div></x-screen-footer>
</x-location>`;
i++;
}
}
all_slide_footer = `<div class='slide-footer center'>
<span class="big-p">(</span>
<x-button class="slides-restart" href="#(1)">⟳</x-button>
<x-button class="slides-titles" href="#(titles)"
>-/${num_slides}</x-button>
<x-button class="all-slides" href="#(1)">≡</x-button>
<span class="big-p">)</span></div>`;
all_slides += `</div></x-present>`;
game_contents += `<x-location id="(all)">
${all_slides}<x-screen-footer>
<div class='slide-footer center'
>${all_slide_footer}</div></x-screen-footer>
</x-location>`;
x_game.innerHTML = game_contents;
let body = sel('body')[0];
let start_x = 0;
let start_swipe_ck = (e) => {
start_x = (e.changedTouches ? e.changedTouches[0] : e).clientX;
};
let end_swipe_ck = (e) => {
let dx = (e.changedTouches ? e.changedTouches[0] : e).clientX -
start_x;
if (dx > 30) {
let previous = sel('#screen-footer .slides-previous')[0];
if (previous) {
previous.click();
}
} else if (dx < -30 ) {
let next = sel('#screen-footer .slides-next')[0];
if (next) {
next.click();
}
}
}
body.addEventListener('mousedown', start_swipe_ck, false);
body.addEventListener('touchstart', start_swipe_ck, false);
body.addEventListener('mouseup', end_swipe_ck, false);
body.addEventListener('touchend', end_swipe_ck, false);
body.addEventListener('keydown', (event) => {
let hash = window.location.hash ?? 1;
let page_num = parseInt(hash.substring(2, hash.length - 1));
page_num = (isNaN(page_num)) ? 1 : page_num;
if (['ArrowLeft'].includes(event.code) && page_num > 1) {
let previous = sel('#screen-footer .slides-previous')[0];
if (previous) {
previous.click();
}
} else if (['Space', 'ArrowRight', 'Enter'].includes(event.code)
&& page_num < num_slides) {
let next = sel('#screen-footer .slides-next')[0];
if (next) {
next.click();
}
} else if (['KeyT'].includes(event.code)) {
let titles = sel('#screen-footer .slides-titles')[0];
if (titles) {
titles.click();
}
} else if (['KeyF', 'KeyH'].includes(event.code)) {
let slide_footer = sel('#game-screen .slide-footer')[0];
if (slide_footer.style.display != 'none') {
slide_footer.style.display = 'none';
sessionStorage['show_footer'] = false;
} else {
slide_footer.style.display = 'block';
sessionStorage['show_footer'] = true;
}
} else if (event.code == 'KeyR' || event.code == 'Digit1') {
window.location = `#(1)`;
} else if (event.code == 'KeyA') {
if (mc().position == '(all)') {
window.location = "#(1)";
} else {
window.location = "#(all)";
}
}
});
finishInitGame();
if (window.location.hash) {
game.takeTurn(window.location.hash);
}
}
window.addEventListener("load", (event) => {
let body = sel("body")[0];
let has_onload = body.getAttribute("onload") ? true : false;
let is_slides = sel("x-slides")[0] ? true : false;
let is_game = sel("x-game")[0] ? true : false;
if (has_onload) {
return;
} else if (is_slides) {
initSlides();
} else if (is_game) {
initGame();
}
});