Skip to content
Snippets Groups Projects
spniCore.js 41.1 KiB
Newer Older
  • Learn to ignore specific revisions
  • /********************************************************************************
    
     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 */
    
    FarawayVision's avatar
    FarawayVision committed
    var DEBUG = false;
    
    var EPILOGUES_ENABLED = true;
    
    var EPILOGUES_UNLOCKED = false;
    
    var EPILOGUE_BADGES_ENABLED = true;
    
    var ALT_COSTUMES_ENABLED = false;
    
    var USAGE_TRACKING = 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/';
    
    /*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';
    
    
    
    
    /* game table */
    var tableOpacity = 1;
    $gameTable = $('#game-table');
    
    /* useful variables */
    var BLANK_PLAYER_IMAGE = "opponents/blank.png";
    
    /* player array */
    
    /* 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 = '';
    
    var gameID = '';
    
    
    /**********************************************************************
     * Screens & Modals
     **********************************************************************/
    
    /* Screens */
    
    Joseph Kantel's avatar
    Joseph Kantel committed
    $warningScreen = $('#warning-screen');
    
    $titleScreen = $('#title-screen');
    $selectScreen = $('#main-select-screen');
    $individualSelectScreen = $('#individual-select-screen');
    $groupSelectScreen = $('#group-select-screen');
    $gameScreen = $('#game-screen');
    $epilogueScreen = $('#epilogue-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,
    
            'game': gameID,
    
            'type': bugType,
            'character': bugCharacter,
    
            'description': userDesc,
    
            '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+'/';
    
        this.first = '';
        this.last = '';
    
        this.folders = undefined;
    
        this.size = eSize.MEDIUM;
        this.intelligence = eIntelligence.AVERAGE;
        this.gender = eGender.MALE;
        this.timer = 20;
        this.scale = undefined;
    
        this.xml = null;
        this.metaXml = null;
    
        this.selected_costume = null;
        this.alt_costume = null;
        this.default_costume = null;
    
        this.resetState();
    
    /*******************************************************************
    
    FarawayVision's avatar
    FarawayVision committed
     * (Re)Initialize the player properties that change during a game
    
     *******************************************************************/
    
    Player.prototype.resetState = function () {
    
        this.out = this.finished = this.exposed = false;
    	this.forfeit = "";
    	this.stage = this.current = this.consecutiveLosses = 0;
    	this.timeInStage = -1;
    	this.markers = {};
    
    	if (this.xml !== null) {
    
            /* 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.
             */
    
    		this.allStates = parseDialogue(this.xml.find('start'), this);
    		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);
            }
    
    		/* Load the player's wardrobe. */
    
        	/* Find and grab the wardrobe tag */
    
        	$wardrobe = appearance.wardrobe;
    
        	/* find and create all of their clothing */
    
            var clothingArr = [];
    
        	$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);
    
        		clothingArr.push(newClothing);
    
            this.clothing = clothingArr;
    
    		this.startingLayers = clothingArr.length;
    		this.mostlyClothed = checkPlayerStatus(this, STATUS_DECENT);
    
    	this.updateLabel();
    
    	this.updateFolder();
    
    Player.prototype.getIntelligence = function () {
        return this.intelligence; // Opponent uses getByStage()
    };
    
    /* 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 () { }
    
    Player.prototype.updateBehaviour = function() { }
    
    
    /*****************************************************************************
     * Subclass of Player for AI-controlled players.
     ****************************************************************************/
    
    function Opponent (id, $metaXml, status, releaseNumber) {
    
        this.id = id;
        this.folder = 'opponents/'+id+'/';
    
        this.metaXml = $metaXml;
    
        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 = fixupDialogue($metaXml.find('description').html());
    
        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;
    
        /* Attempt to preload this opponent's picture for selection. */
        new Image().src = 'opponents/'+id+'/'+this.image;
    
        this.alternate_costumes = $metaXml.find('alternates').find('costume').map(function () {
            return {
    
                'label': $(this).text(),
    
    }
    
    Opponent.prototype = Object.create(Player.prototype);
    Opponent.prototype.constructor = Opponent;
    
    
    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;
    }
    
    
    Opponent.prototype.isLoaded = function() {
    	return this.xml != undefined;
    }
    
    
    Opponent.prototype.onSelected = function() {
        this.resetState();
    	console.log(this.slot+": "+this);
    
        this.preloadStageImages(-1);
    
    	this.updateBehaviour(SELECTED);
    	updateSelectionVisuals();
    }
    
    
    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);
    }
    
    Opponent.prototype.getByStage = function (arr, stage) {
    
        if (stage === undefined) stage = this.stage;
    
        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 <= stage) {
    
                bestFit = $(arr[i]).text();
                bestFitStage = startStage;
            }
        }
        return bestFit;
    };
    
    
    Opponent.prototype.selectAlternateCostume = function (folder) {
        this.selected_costume = folder;
    
    Opponent.prototype.getIntelligence = function () {
        return this.getByStage(this.intelligence) || eIntelligence.AVERAGE;
    };
    
    
    Opponent.prototype.loadAlternateCostume = function () {
        $.ajax({
            type: "GET",
    
            dataType: "text",
            success: function (xml) {
                var $xml = $(xml);
    
                this.alt_costume = {
    
                    labels: $xml.find('label'),
    
                    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;
        }
    
        if (this.alt_costume.tags) {
    
            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.alt_costume = null;
    
        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.
     ************************************************************/
    
    Opponent.prototype.loadBehaviour = function (slot) {
    
    ReformCopyright's avatar
    ReformCopyright committed
        this.slot = slot;
        if (this.isLoaded()) {
    
            if (this.selected_costume) {
                this.loadAlternateCostume();
            } else {
                this.onSelected();
            }
    
    ReformCopyright's avatar
    ReformCopyright committed
            return;
        }
    
        fetchCompressedURL(
    		'opponents/' + this.id + "/behaviour.xml",
    
    		/* Success callback.
             * 'this' is bound to the Opponent object.
             */
    		function(xml) {
    
                var $xml = $(xml);
    
                this.xml = $xml;
                this.size = $xml.find('size').text();
                this.timer = Number($xml.find('timer').text());
                this.intelligence = $xml.find('intelligence');
    
                this.default_costume = {
    
                    labels: $xml.find('label'),
                    tags: null,
                    folders: this.folder,
                    wardrobe: $xml.find('wardrobe')
                };
    
                var tags = $xml.find('tags');
    
                var tagsArray = [this.id];
    
                if (typeof tags !== typeof undefined && tags !== false) {
                    $(tags).find('tag').each(function () {
    
                        tagsArray.push($(this).text());
    
                this.tags = tagsArray;
    
                var targetedLines = {};
    
                $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);
                });
    
                //var newPlayer = createNewPlayer(opponent.id, first, last, labels, gender, size, intelligence, Number(timer), opponent.scale, tagsArray, $xml);
                this.targetedLines = targetedLines;
    
                if (ALT_COSTUMES_ENABLED && this.selected_costume) {
    
                    this.loadAlternateCostume();
                } else {
                    this.onSelected();
                }
    
    		}.bind(this),
    
    		/* Error callback. */
            function(err) {
    
                console.log("Failed reading \""+this.id+"\" behaviour.xml");
    
            }.bind(this)
    
    Player.prototype.getImagesForStage = function (stage) {
        if(!this.xml) return [];
    
        var imageSet = {};
    
        var folder = this.folders ? this.getByStage(this.folders, stage) : this.folder;
    
        var selector = (stage == -1 ? 'start, stage[id=1] case[tag=game_start]'
                        : 'stage[id='+stage+'] case');
    
        this.xml.find(selector).each(function () {
    
            var target = $(this).attr('target'), alsoPlaying = $(this).attr('alsoPlaying'),
                filter = $(this).attr('filter');
            // Skip cases requiring a character that isn't present
            if ((target === undefined || players.some(function(p) { return p.id === target; }))
                && (alsoPlaying === undefined || players.some(function(p) { return p.id === alsoPlaying; }))
                && (filter === undefined || players.some(function(p) { return p.tags.indexOf(filter) >= 0; })))
            {
                $(this).children('state').each(function () {
                    imageSet[folder+$(this).attr('img')] = true;
                })
            }
    
        });
        return Object.keys(imageSet);
    };
    
    Player.prototype.preloadStageImages = function (stage) {
        this.getImagesForStage(stage)
            .forEach(function(fn) { new Image().src = fn; }, this );
    };
    
    /**********************************************************************
     *****              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);
    
        players[HUMAN_PLAYER] = humanPlayer;
    
    	/* 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);
    
    	/* show the title screen */
    
    Joseph Kantel's avatar
    Joseph Kantel committed
    	$warningScreen.show();
    
        autoResizeFont();
    
        /* 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);
        }
    
    function loadConfigFile () {
    
            type: "GET",
    		url: "config.xml",
    		dataType: "text",
    
    		success: function(xml) {
    
    			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 _epilogues_unlocked = $(xml).find('epilogues-unlocked').text().trim();
                if (_epilogues_unlocked.toLowerCase() === 'false') {
                    EPILOGUES_UNLOCKED = false;
                } else {
                    EPILOGUES_UNLOCKED = true;
                    console.error('All epilogues unlocked in config file. You better be using this for development only and not cheating!');
                }
    
    
                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");
    			});
    
    Joseph Kantel's avatar
    Joseph Kantel committed
    function enterTitleScreen() {
        $warningScreen.hide();
        $titleScreen.show();
    }
    
    
    /************************************************************
     * Transitions between two screens.
     ************************************************************/
    function screenTransition (first, second) {
    	first.hide();
    	second.show();
    }
    
    /************************************************************
     * 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++) {
    
    		if (players[i] != null) {
    
                players[i].resetState();
    
    	updateAllBehaviours(null, SELECTED);
    
    }
    
    /************************************************************
     * Restarts the game.
     ************************************************************/
    function restartGame () {
        KEYBINDINGS_ENABLED = false;
    
    
    	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();
    
        forceTableVisibility(true);
    
    	/* there is only one call to this right now */
    	$epilogueSelectionModal.hide();
    	$gameScreen.hide();
    	$epilogueScreen.hide();
    
    	$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. */
    
    function updateBugReportOutput() {
    
        $('#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 () {
    
        /* Set up possible bug report types. */
    
        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;
    
    /************************************************************
     * 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');
    }
    
    
    /************************************************************
     * The player clicked the player tags button. Shows the player tags modal.
     ************************************************************/
    function showPlayerTagsModal () {
        if (document.forms['player-tags'].elements.length <= 6) {
            // Safari doesn't support color inputs properly!
            var hairColorPicker = document.getElementById('hair_color_picker');