Newer
Older
/********************************************************************************
metodLD
committed
This file contains the variables and functions that forms the core of the game.
Anything that is needed game-wide is kept here.
********************************************************************************/
/**********************************************************************
* Game Wide Constants
**********************************************************************/
/* General Constants */
var EPILOGUES_ENABLED = true;
var EPILOGUE_BADGES_ENABLED = true;
var ALT_COSTUMES_ENABLED = false;
var BASE_FONT_SIZE = 14;
var BASE_SCREEN_WIDTH = 100;
var USAGE_TRACKING_ENDPOINT = 'https://spnati.faraway-vision.io/usage/report';
var BUG_REPORTING_ENDPOINT = 'https://spnati.faraway-vision.io/usage/bug_report';
/* Game Wide Constants */
var HUMAN_PLAYER = 0;
/* Directory Constants */
var IMG = 'img/';

ReformCopyright
committed
var backgroundImage;
/*var OPP = 'opponents/';
#The "OPP" folder abbreviation was used to slightly shorten a few lines in spniSelect that looked for opponents in the opponents folder.
#Now that opponents can be specified in any folder, this is no longer required.*/
/* Gender Images */
var MALE_SYMBOL = IMG + 'male.png';
var FEMALE_SYMBOL = IMG + 'female.png';
var includedOpponentStatuses = {};
/* game table */
var tableOpacity = 1;
$gameTable = $('#game-table');
/* useful variables */
var BLANK_PLAYER_IMAGE = "opponents/blank.png";
/* player array */

ReformCopyright
committed
var players = Array(5);

ReformCopyright
committed
/* Current timeout ID, so we can cancel it when restarting the game in order to avoid trouble. */
var timeoutID;
/**********************************************************************
* Game Wide Global Variables
**********************************************************************/
var table = new Table();
var jsErrors = [];
var sessionID = '';
/**********************************************************************
* Screens & Modals
**********************************************************************/
/* Screens */
$titleScreen = $('#title-screen');
$selectScreen = $('#main-select-screen');
$individualSelectScreen = $('#individual-select-screen');
$groupSelectScreen = $('#group-select-screen');
$gameScreen = $('#game-screen');
$epilogueScreen = $('#epilogue-screen');
metodLD
committed
$galleryScreen = $('#gallery-screen');
var allScreens = [$warningScreen, $titleScreen, $selectScreen, $individualSelectScreen, $groupSelectScreen, $gameScreen, $epilogueScreen, $galleryScreen];
/* Modals */
$searchModal = $('#search-modal');
$groupSearchModal = $('#group-search-modal');
$creditModal = $('#credit-modal');
$versionModal = $('#version-modal');
$gameSettingsModal = $('#game-settings-modal');
$bugReportModal = $('#bug-report-modal');
$usageTrackingModal = $('#usage-reporting-modal');
$playerTagsModal = $('#player-tags-modal');
/* Screen State */
$previousScreen = null;
/* CSS rules for arrow offsets */
var bubbleArrowOffsetRules;
/********************************************************************************
* Game Wide Utility Functions
********************************************************************************/
function getReportedOrigin () {
var origin = window.location.origin;
if (origin.toLowerCase().startsWith('file:')) {
return '<local filesystem origin>';
} else {
return origin;
}
}
/* Gathers most of the generic information for an error report. */
function compileBaseErrorReport(userDesc, bugType) {
var tableReports = [];
for (let i=1;i<players.length;i++) {
if (players[i]) {
playerData = {
'id': players[i].id,
'slot': i,
'stage': players[i].stage,
'timeInStage': players[i].timeInStage,
'markers': players[i].markers
}
if (players[i].chosenState) {
playerData.currentLine = players[i].chosenState.dialogue;
playerData.currentImage = players[i].chosenState.image;
}
tableReports[i-1] = playerData;
} else {
tableReports[i-1] = null;
}
}
var circumstances = {
'userAgent': navigator.userAgent,
'origin': getReportedOrigin(),
'currentRound': currentRound,
'currentTurn': currentTurn,
'visibleScreens': []
}
if (gamePhase) {
circumstances.gamePhase = gamePhase[0];
}
for (let i=0;i<allScreens.length;i++) {
if (allScreens[i].css('display') !== 'none') {
circumstances.visibleScreens.push(allScreens[i].attr('id'));
}
}
var bugCharacter = null;
if (bugType.startsWith('character')) {
bugCharacter = bugType.split('-', 2)[1];
bugType = 'character';
}
return {
'date': (new Date()).toISOString(),
'session': sessionID,
'type': bugType,
'character': bugCharacter,
'circumstances': circumstances,
'table': tableReports,
'player': {
'gender': players[HUMAN_PLAYER].gender,
'size': players[HUMAN_PLAYER].size,
},
'jsErrors': jsErrors,
};
}
window.addEventListener('error', function (ev) {
jsErrors.push({
'date': (new Date()).toISOString(),
'type': ev.error.name,
'message': ev.message,
'filename': ev.filename,
'lineno': ev.lineno,
'stack': ev.error.stack
});
if (USAGE_TRACKING) {
var report = compileBaseErrorReport('Automatically generated after Javascript error.', 'auto');
$.ajax({
url: BUG_REPORTING_ENDPOINT,
method: 'POST',
data: JSON.stringify(report),
contentType: 'application/json',
error: function (jqXHR, status, err) {
console.error("Could not send bug report - error "+status+": "+err);
},
});
}
});
/* Fetch a possibly compressed file.
* Attempts to fetch a compressed version of the file first,
* then fetches the uncompressed version of the file if that isn't found.
*/
function fetchCompressedURL(baseUrl, successCb, errorCb) {
/*
* The usual Jquery AJAX request function doesn't play nice with
* the binary-encoded data we'll get here, so we do the XHR manually.
* (I would use fetch() were it not for compatibility issues.)
*/
var req = new XMLHttpRequest();
req.open('GET', baseUrl+'.gz', true);
req.responseType = 'arraybuffer';
req.onload = function(ev) {
if (req.status < 400 && req.response) {
var data = new Uint8Array(req.response);
var decompressed = pako.inflate(data, { to: 'string' });
successCb(decompressed);
} else if (req.status === 404) {
$.ajax({
type: "GET",
url: baseUrl,
dataType: "text",
success: successCb,
error: errorCb,
});
} else {
errorCb();
}
}
req.onerror = function(err) {
$.ajax({
type: "GET",
url: baseUrl,
dataType: "text",
success: successCb,
error: errorCb,
});
}
/**********************************************************************
***** Player Object Specification *****
**********************************************************************/
/************************************************************
* Creates and returns a new player object based on the
* supplied information.
*
* folder (string), the path to their folder
* first (string), their first name.
* last (string), their last name.
* labels (string or XML element), what's shown on screen and what other players refer to them as.
* Can vary by stage.
* size (string): Their level of endowment
* intelligence (string or XML element), the name of their AI algorithm.
* Can vary by stage.
* gender (constant), their gender.
* clothing (array of Clothing objects), their clothing.
* timer (integer), time until forfeit is finished.
* state (array of PlayerState objects), their sequential states.
* xml (jQuery object), the player's loaded behaviour.xml file.
* metaXml (jQuery object), the player's loaded meta.xml file.
************************************************************/
function Player (id) {
this.id = id;
this.folder = 'opponents/'+id+'/';

FarawayVision
committed
this.base_folder = 'opponents/'+id+'/';
this.first = '';
this.last = '';
this.labels = undefined;
this.size = eSize.MEDIUM;
this.intelligence = eIntelligence.AVERAGE;
this.gender = eGender.MALE;
this.timer = 20;
this.scale = undefined;
ReformCopyright
committed
this.tags = [id];
this.xml = null;
this.metaXml = null;
this.selected_costume = null;
this.alt_costume = null;
this.default_costume = null;
/*******************************************************************
* (Re)Initialize the player properties that change during a game
*******************************************************************/
this.out = this.finished = this.exposed = false;
this.forfeit = "";
this.stage = this.current = this.consecutiveLosses = 0;
this.timeInStage = -1;
this.markers = {};
ReformCopyright
committed
this.exposed = { upper: false, lower: false };
/* Load in the legacy "start" lines, and also
* initialize player.chosenState to the first listed line.
* This may be overridden by later updateBehaviour calls if
* the player has (new-style) selected or game start case lines.
*/
var allStates = [];
this.xml.find('start').each(function () {
allStates.push(new State($(this)));
});
this.allStates = allStates;
this.chosenState = this.allStates[0];
if (!this.chosenState) {
/* If the opponent does not have legacy start lines then select
* a new-style selected line immediately.
* Prevents a crash triggered by selected, unselecting, and re-selecting
* an opponent with no legacy starting lines.
*/
this.updateBehaviour(SELECTED);
}
var appearance = this.default_costume;
if (ALT_COSTUMES_ENABLED && this.alt_costume) {
appearance = this.alt_costume;
}
this.labels = appearance.labels;
this.folders = appearance.folders;
if (appearance.tags) {
var alt_tags = appearance.tags.find('tag').each(function (idx, elem) {
var $elem = $(elem);
var tag = $elem.text();
var removed = $elem.attr('remove') || '';
if (removed.toLowerCase() === 'true') {
if (this.tags.indexOf(tag) > 0) {
this.tags.splice(this.tags.indexOf(tag), 1);
}
} else {
this.tags.push(tag);
}
}.bind(this));
if (appearance.id) {
this.tags.push(appearance.id);
}
/* Find and grab the wardrobe tag */
/* find and create all of their clothing */
$wardrobe.find('clothing').each(function () {
var formalName = $(this).attr('formalName');
var genericName = $(this).attr('genericName') || $(this).attr('lowercase');
var type = $(this).attr('type');
var position = $(this).attr('position');
var plural = ['true', 'yes'].indexOf($(this).attr('plural')) >= 0;
var newClothing = createNewClothing(formalName, genericName, type, position, null, plural, 0);
ReformCopyright
committed
this.startingLayers = clothingArr.length;
this.mostlyClothed = checkPlayerStatus(this, STATUS_DECENT);
Player.prototype.getIntelligence = function () {
return this.intelligence; // Opponent uses getByStage()
};

ReformCopyright
committed
/* These shouldn't do anything for the human player, but exist as empty functions
to make it easier to iterate over the entire players[] array. */
Player.prototype.updateLabel = function () { }
Player.prototype.updateFolder = function () { }

ReformCopyright
committed
Player.prototype.updateBehaviour = function() { }
/*****************************************************************************
* Subclass of Player for AI-controlled players.
****************************************************************************/
function Opponent (id, $metaXml, status, releaseNumber) {
this.id = id;
this.folder = 'opponents/'+id+'/';

FarawayVision
committed
this.base_folder = 'opponents/'+id+'/';
this.enabled = $metaXml.find('enabled').text();
this.status = status;
this.first = $metaXml.find('first').text();
this.last = $metaXml.find('last').text();
this.label = $metaXml.find('label').text();
this.image = $metaXml.find('pic').text();
this.gender = $metaXml.find('gender').text();
this.height = $metaXml.find('height').text();
this.source = $metaXml.find('from').text();
this.artist = $metaXml.find('artist').text();
this.writer = $metaXml.find('writer').text();
this.description = $metaXml.find('description').text();

ReformCopyright
committed
this.endings = $metaXml.find('epilogue');
this.ending = this.endings.length > 0 || $metaXml.find('has_ending').text() === "true";
this.layers = parseInt($metaXml.find('layers').text(), 10);
this.scale = Number($metaXml.find('scale').text()) || 100.0;
this.tags = $metaXml.find('tags').children().map(function() { return $(this).text(); }).get();
this.release = parseInt(releaseNumber, 10) || Number.POSITIVE_INFINITY;
this.alternate_costumes = $metaXml.find('alternates').find('costume').map(function () {
return {

FarawayVision
committed
'folder': $(this).attr('folder'),
'label': $(this).text(),
'image': $(this).attr('img')
};
}).get();
}
Opponent.prototype = Object.create(Player.prototype);
Opponent.prototype.constructor = Opponent;

ReformCopyright
committed
Opponent.prototype.clone = function() {
var clone = Object.create(Opponent.prototype);
/* This should be deep enough for our purposes. */
for (var prop in this) {
if (this[prop] instanceof Array) {
clone[prop] = this[prop].slice();
} else {
clone[prop] = this[prop];
}
}
return clone;
}

ReformCopyright
committed
Opponent.prototype.isLoaded = function() {
return this.xml != undefined;
}
Opponent.prototype.onSelected = function() {
this.resetState();
console.log(this.slot+": "+this);
this.updateBehaviour(SELECTED);
updateSelectionVisuals();
}

ReformCopyright
committed
Opponent.prototype.updateLabel = function () {
if (this.labels) this.label = this.getByStage(this.labels);
}
Opponent.prototype.updateFolder = function () {
if (this.folders) this.folder = this.getByStage(this.folders);
}

ReformCopyright
committed
Opponent.prototype.getImagesForStage = function (stage) {
if(!this.xml) return [];

ReformCopyright
committed
var imageSet = {};
var folder = this.folder;
this.xml.find('stage[id="'+stage+'"] state').each(function () {
imageSet[folder+$(this).attr('img')] = true;
});
return Object.keys(imageSet);
};
Opponent.prototype.getByStage = function (arr) {
if (typeof(arr) === "string") {
return arr;
}
var bestFitStage = -1;
var bestFit = null;
for (var i = 0; i < arr.length; i++) {
var startStage = arr[i].getAttribute('stage');
startStage = parseInt(startStage, 10) || 0;
if (startStage > bestFitStage && startStage <= this.stage) {
bestFit = $(arr[i]).text();
bestFitStage = startStage;
}
}
return bestFit;
};

FarawayVision
committed
Opponent.prototype.selectAlternateCostume = function (folder) {
this.selected_costume = folder;

ReformCopyright
committed
Opponent.prototype.getIntelligence = function () {
return this.getByStage(this.intelligence) || eIntelligence.AVERAGE;
};
Opponent.prototype.loadAlternateCostume = function () {
$.ajax({
type: "GET",

FarawayVision
committed
url: this.selected_costume+'costume.xml',
dataType: "text",
success: function (xml) {
var $xml = $(xml);
this.alt_costume = {

FarawayVision
committed
id: $xml.find('id').text(),
tags: $xml.find('tags'),
folders: $xml.find('folder'),
wardrobe: $xml.find('wardrobe')
};
this.onSelected();
}.bind(this),
error: function () {
console.error("Failed to load alternate costume: "+this.selected_costume);
},
})
}
Opponent.prototype.unloadAlternateCostume = function () {
if (!this.alt_costume) {
return;
}
this.alt_costume.tags.find('tag').each(function (idx, elem) {
var $elem = $(elem);
var tag = $elem.text();
var removed = $elem.attr('remove') || '';
if (removed.toLowerCase() === 'true') {
this.tags.push(tag); // tag was previously removed, readd it
} else {
if (this.tags.indexOf(tag) > 0) {
// remove added tag
this.tags.splice(this.tags.indexOf(tag), 1);
}
}
this.tags.splice(this.tags.indexOf(this.alt_costume.id), 1);
this.selectAlternateCostume(null);
/************************************************************
* Loads and parses the start of the behaviour XML file of the
* given opponent.
*
* The onLoadFinished parameter must be a function capable of
* receiving a new player object and a slot number.
************************************************************/

ReformCopyright
committed
Opponent.prototype.loadBehaviour = function (slot) {
if (this.selected_costume) {
this.loadAlternateCostume();
} else {
this.onSelected();
}
fetchCompressedURL(
'opponents/' + this.id + "/behaviour.xml",
/* Success callback.
* 'this' is bound to the Opponent object.
*/
function(xml) {
this.xml = $xml;
this.size = $xml.find('size').text();
this.timer = Number($xml.find('timer').text());
this.intelligence = $xml.find('intelligence');
labels: $xml.find('label'),
tags: null,
folders: this.folder,
wardrobe: $xml.find('wardrobe')
};
if (typeof tags !== typeof undefined && tags !== false) {
$(tags).find('tag').each(function () {
$xml.find('case[target]>state, case[alsoPlaying]>state').each(function() {
var $case = $(this.parentNode);
['target', 'alsoPlaying'].forEach(function(attr) {
var id = $case.attr(attr);
if (id) {
if (!(id in targetedLines)) { targetedLines[id] = { count: 0, seen: {} }; }
if (!(this.textContent in targetedLines[id].seen)) {
targetedLines[id].seen[this.textContent] = true;
targetedLines[id].count++;
}
}
}, this);
});
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
// Create Case objects for all cases, and store them indexed
// by case tag.
var allCases = {};
this.xml.find('behaviour').find('stage').each(function () {
var $stage = $(this);
var stage = $stage.attr('id');
$stage.find('case').each(function () {
var case = new Case($(this), stage);
if(!allCases[case.tag]) {
allCases[case.tag] = [];
}
allCases[case.tag].push(case);
});
});
this.xml.find('behaviour').find('case').each(function () {
var case = new Case($(this), null);
if(!allCases[case.tag]) {
allCases[case.tag] = [];
}
allCases[case.tag].push(case);
})
this.allCases = allCases;
if (ALT_COSTUMES_ENABLED && this.selected_costume) {
this.loadAlternateCostume();
} else {
this.onSelected();
}
/* Error callback. */
function(err) {
console.log("Failed reading \""+this.id+"\" behaviour.xml");

ReformCopyright
committed
delete players[this.slot];
/**********************************************************************
***** Overarching Game Flow Functions *****
**********************************************************************/
/************************************************************
* Loads the initial content of the game.
************************************************************/
function initialSetup () {
/* start by creating the human player object */
var humanPlayer = new Player('human'); //createNewPlayer("human", "", "", "", eGender.MALE, eSize.MEDIUM, eIntelligence.AVERAGE, 20, undefined, [], null);
/* enable table opacity */
tableOpacity = 1;
$gameTable.css({opacity:1});
/* load the all content */
loadTitleScreen();
selectTitleCandy();
/* Make sure that the config file is loaded before processing the
opponent list, so that includedOpponentStatuses is populated. */
loadConfigFile().always(loadSelectScreen);
save.loadCookie();
/* Generate a random session ID. */
sessionID = generateRandomID();
/* Construct a CSS rule for every combination of arrow direction, screen, and pseudo-element */
bubbleArrowOffsetRules = [];
for (var i = 1; i <= 4; i++) {
var pair = [];
[["up", "down"], ["left", "right"]].forEach(function(p) {
var index = document.styleSheets[2].cssRules.length;
var rule = p.map(function(d) {
return ["select", "game"].map(function(s) {
return ["before", "after"].map(function(r) {
return '#'+s+'-bubble-'+i+'>.dialogue-bubble.arrow-'+d+'::'+r;
}).join(', ');
}).join(', ');
}).join(', ') + ' {}';
document.styleSheets[2].insertRule(rule, index);
pair.push(document.styleSheets[2].cssRules[index]);
});
bubbleArrowOffsetRules.push(pair);
}
return $.ajax({
type: "GET",
url: "config.xml",
dataType: "text",
var _epilogues = $(xml).find('epilogues').text();
if(_epilogues.toLowerCase() === 'false') {
EPILOGUES_ENABLED = false;
console.log("Epilogues are disabled.");
$("#title-gallery-edge").hide();
} else {
console.log("Epilogues are enabled.");
EPILOGUES_ENABLED = true;
}
var _epilogue_badges = $(xml).find('epilogue_badges').text();
if(_epilogue_badges.toLowerCase() === 'false') {
EPILOGUE_BADGES_ENABLED = false;
console.log("Epilogue badges are disabled.");
} else {
console.log("Epilogue badges are enabled.");
EPILOGUE_BADGES_ENABLED = true;
}
var _debug = $(xml).find('debug').text();
if (_debug === "true") {
DEBUG = true;
console.log("Debugging is enabled");
}
else {
DEBUG = false;
console.log("Debugging is disabled");
}
var _alts = $(xml).find('alternate-costumes').text();
if(_alts === "true") {
ALT_COSTUMES_ENABLED = true;
console.log("Alternate costumes enabled");
} else {
ALT_COSTUMES_ENABLED = false;
console.log("Alternate costumes disabled");
}
$(xml).find('include-status').each(function() {
includedOpponentStatuses[$(this).text()] = true;
console.log("Including", $(this).text(), "opponents");
});
function enterTitleScreen() {
$warningScreen.hide();
$titleScreen.show();
}
/************************************************************
* Transitions between two screens.
************************************************************/
function screenTransition (first, second) {
first.hide();
second.show();
}
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
/************************************************************
* Switches to the next screen based on the screen provided.
************************************************************/
function advanceToNextScreen (screen) {
if (screen == $titleScreen) {
/* advance to the select screen */
screenTransition($titleScreen, $selectScreen);
} else if (screen == $selectScreen) {
/* advance to the main game screen */
$selectScreen.hide();
loadGameScreen();
$gameScreen.show();
}
}
/************************************************************
* Switches to the last screen based on the screen provided.
************************************************************/
function returnToPreviousScreen (screen) {
if (screen == $selectScreen) {
/* return to the title screen */
$selectScreen.hide();
$titleScreen.show();
}
}
/************************************************************
* Resets the game state so that the game can be restarted.
************************************************************/
function resetPlayers () {
for (var i = 0; i < players.length; i++) {

ReformCopyright
committed
timers[i] = 0;
updateAllBehaviours(null, SELECTED);
}
/************************************************************
* Restarts the game.
************************************************************/
function restartGame () {
KEYBINDINGS_ENABLED = false;

ReformCopyright
committed
clearTimeout(timeoutID); // No error if undefined or no longer valid
timeoutID = autoForfeitTimeoutID = undefined;
stopCardAnimations();
/* enable table opacity */
tableOpacity = 1;
$gameTable.css({opacity:1});
$gamePlayerClothingArea.show();
$gamePlayerCardArea.show();
/* trigger screen refreshes */
updateSelectionVisuals();
updateAllGameVisuals();
selectTitleCandy();
/* there is only one call to this right now */
$epilogueSelectionModal.hide();
$gameScreen.hide();
$epilogueScreen.hide();

ReformCopyright
committed
loadClothing();
$titleScreen.show();
}
/**********************************************************************
***** Interaction Functions *****
**********************************************************************/
/*
* Bug Report Modal functions
*/
function getBugReportJSON() {
var desc = $('#bug-report-desc').val();
var type = $('#bug-report-type').val();
var character = undefined;
var report = compileBaseErrorReport(desc, type);
return JSON.stringify(report);
}
function updateBugReportSendButton() {
if($('#bug-report-desc').val().trim().length > 0) {
$("#bug-report-modal-send-button").removeAttr('disabled');
} else {
$("#bug-report-modal-send-button").attr('disabled', 'true');
}
}
$('#bug-report-desc').keyup(updateBugReportSendButton);
/* Update the bug report text dump. */
$('#bug-report-output').val(getBugReportJSON());
$('#bug-report-status').text("");
updateBugReportSendButton();
}
function copyBugReportOutput() {
var elem = $('#bug-report-output')[0];
elem.select();
document.execCommand("copy");
}
function sendBugReport() {
if($('#bug-report-desc').val().trim().length == 0) {
$('#bug-report-status').text("Please enter a description first!");
return false;
}
$.ajax({
url: BUG_REPORTING_ENDPOINT,
method: 'POST',
data: getBugReportJSON(),
contentType: 'application/json',
error: function (jqXHR, status, err) {
console.error("Could not send bug report - error "+status+": "+err);
$('#bug-report-status').text("Failed to send bug report (error "+status+")");
success: function () {
$('#bug-report-status').text("Bug report sent!");
$('#bug-report-desc').val("");
closeBugReportModal();
}
$('#bug-report-type').change(updateBugReportOutput);
$('#bug-report-desc').change(updateBugReportOutput);
$('#bug-report-copy-btn').click(copyBugReportOutput);
/************************************************************
* The player clicked a bug-report button. Shows the bug reports modal.
************************************************************/
function showBugReportModal () {
var bugReportTypes = [
['freeze', 'Game Freeze or Crash'],
['display', 'Game Graphical Problem'],
['other', 'Other Game Issue'],
]
for (var i=1;i<5;i++) {
if (players[i]) {
var mixedCaseID = players[i].id.charAt(0).toUpperCase()+players[i].id.substring(1);
bugReportTypes.push(['character-'+players[i].id, 'Character Defect ('+mixedCaseID+')']);
}
}
$('#bug-report-type').empty().append(bugReportTypes.map(function (t) {
return $('<option value="'+t[0]+'">'+t[1]+'</option>');
}));
$('#bug-report-modal span[data-toggle="tooltip"]').tooltip();
updateBugReportOutput();
KEYBINDINGS_ENABLED = false;
$bugReportModal.modal('show');
}
function closeBugReportModal() {
KEYBINDINGS_ENABLED = true;
$bugReportModal.modal('hide');
}
/*
* Show the usage tracking consent modal.
*/
function showUsageTrackingModal() {
$usageTrackingModal.modal('show');
}
function enableUsageTracking() {
save.data.askedUsageTracking = true;
USAGE_TRACKING = true;
save.saveOptions();
}
function disableUsageTracking() {
save.data.askedUsageTracking = true;
USAGE_TRACKING = false;
save.saveOptions();
}
/************************************************************
* The player clicked the credits button. Shows the credits modal.
************************************************************/
function showCreditModal () {
$creditModal.modal('show');
}
/************************************************************
* The player clicked the version button. Shows the version modal.
************************************************************/
function showVersionModal () {
$versionModal.modal('show');
}