Source: gameloader.js

import {PicoInterface} from "./lib/picointerface.js";
import GlobalAudioManager from "./lib/gamesound.js";
import GlobalAnimManager from "./lib/animationmanager.js";
import MainMenuManager from "./lib/options/menumanager.js";
import iniReader from "./lib/configloader.js";
/**
 * For loading games.
 * @file
 */
/** Game loader, for everything to do with transitions: playing animations, playing sounds, selecting the next game, changing difficulty. 
 * @module gameloader
*/

var ini;
/**
 * Runs all loading behavior. That includes:
 * Keeping track of what game to currently load
 * Transitioning into loaded games
 * Playing/animating transitions from games using the Animation Manager.
 * @class GameLoader
 */
class GameLoader {
    
    /**
     * If {@link DEBUG_TEST} is set to “sequential”, this will be used for the index of the current game played.
     */
    #debug_index = 0;
    /** Did the {@link module:gameloader~GameLoader#gameStarted|gameStarted} function get called? Set to false on {@link transition} */
    #gameLoaded = false;

    /**
     * The dictionary of games to load. 
     * Initialized in {@link module:gameloader~GameLoader#setupGameLoader|setupGameLoader}. Stores the dictionary [Games] from config.ini
     * @tutorial adding-games
     */
    #gamesList;
    /** 
     * Dictionary of games with special loading conditions. 
     * Initialized in {@link module:gameloader~GameLoader#setupGameLoader|setupGameLoader}. Stores the dictionary [Games] from config.ini
    */
    #gamesConfig;
    /** 
     * Dictionary to associate game IDs (how they’re stored in folders) with their actual names.
     * Initialized in {@link module:gameloader~GameLoader#setupGameLoader|setupGameLoader}. Stores the dictionary [Games] from config.ini
    */
    #gameNames;

    /**
     * This WAS supposed to be for loading the ini file. [configloader.js]{@link module:configloader} does this instead, so I’m pretty sure this is useless.
     */
    #setupPromise;
    
    /**
     * Set by {@link MicrogameJam} to control the overall volume of the GAME LOADER’s volume. Has no effect on the volume of the games being loaded.
     */
    masterVolume = 1;
    /** 
     * A list (should probably be a set) of the games that were recently loaded to avoid loading them again.
     * Initialized in {@link module:gameloader~GameLoader#setupGameLoader|setupGameLoader}.
     * Items are arbitrarily removed in {@link module:gameloader~GameLoader#pickGameToLoad|pickGameToLoad}.
    */
    #recentGamesLoaded = [];

    constructor() {
        this.#setupPromise = new Promise(async (resolve) => {
            ini = await iniReader;
            this.#setUpGameLoader();
            resolve();
        });
    }

    /** 
     * Uses localStorage to arbitrarily set difficulty if games are wholly new to the user. Is a list, should also probably be a set.
     * So if a game isn’t in the list in this.transition(), then the difficulty is set to 1 and the game is added to this list.
     * Initialized in {@link module:gameloader~GameLoader#setupGameLoader|setupGameLoader}.

    */
    #alreadyPlayedGames;
    /**
     * Used to set a difficulty curve. Modified in {@link module:gameloader~GameLoader#transition|transition}.
     * Look for the ridiculous max/min/floor/log monstrosity and that’s the difficulty curve.
     */
    #totalGamesPlayed = 0;

    /**
     * Initialize all the properties we need.
     * Set the iframe with ID “game” (in index.html) to call {@link module:gameloader~GameLoader#iframeLoaded} on load.
     * @alias module:gameloader~GameLoader#setupGameLoader
     * @private
     */
    #setUpGameLoader() {
        this.#alreadyPlayedGames = JSON.parse(localStorage.getItem("playedGames"));
        if (this.#alreadyPlayedGames === null) {
            this.#alreadyPlayedGames = [];
        }
        // Add games to be loaded here (CONFIG_FILE adds stuff automatically):
        this.#gamesList = ini["Games"];
        this.#gamesConfig = ini["GamesConfig"];
        this.#gameNames = ini["GameNames"];

        // Since each item in GamesConfig is a list of games:
        for (var key in this.#gamesConfig) {
            this.#gamesConfig[key] = this.#gamesConfig[key].split(",");
        }

        document.getElementById("game").onload = this.#iframeLoaded;
    }

    /**
     * Public read-only accessor of {@link module:gameloader~GameLoader#gameNames}
     *
     * @readonly
     */
    get gameNames() {
        return this.#gameNames;
    }

    // 
    // 
    /**
     * Main function where all the loading actually happens.
     * Call {@link module:gameloader~GameLoader#animateTransition} according to didWin, and increase the difficulty accordingly.
     * @todo Make game picking more robust, add difficulty increases, etc.
     * @param {boolean} didWin Are we meant to treat this as a loss (false) or a win (true)?
     * @return {number} The difficulty number.
     */
    transition(didWin){
        localStorage.setItem("playedGames", JSON.stringify(this.#alreadyPlayedGames));
        /**
         * Reference to {@link module:picointerface|picointerface.js}.
         * Created if a PICO-8 game is detected in {@link module:gameloader~GameLoader#iframeLoaded}.
         * Updated in {@link module:gameloader~GameLoader#loadUpdate} if it exists.
         */
        this.picoInterface = undefined;
        this.#gameLoaded = false;

        // Because Twine saves things to the session:
        sessionStorage.removeItem("Saved Session");
        
        var transitionName = (didWin)? "win" : "lose";

        GlobalAudioManager.play(transitionName + "Jingle", this.masterVolume * 0.4, true);

        this.animateTransition(transitionName);
        // Difficulty change:
        var difficulty = 1;
        if (DEBUG_DIFFICULTY >= 1 && DEBUG_DIFFICULTY <= 3 && Number.isInteger(DEBUG_DIFFICULTY)) {
            difficulty = DEBUG_DIFFICULTY;
        } else if (this.#gameToLoad !== null) {
            if (!this.#alreadyPlayedGames.includes(this.#gameToLoad)) {
                this.#alreadyPlayedGames.push(this.#gameToLoad);
                difficulty = 1;
            } else {
                if (this.#totalGamesPlayed === 0) {
                    difficulty = 1;
                } else {
                    difficulty = Math.max(Math.min(Math.floor(0.9 * Math.log(this.#totalGamesPlayed) + 1), 3), 0);
                }
            }
        }
        this.#totalGamesPlayed++;

        // Return difficulty to the GameInterface:
        return difficulty;
    }

    /**
     * Pick a random game to load using Math.random, as long as it isn’t {@link module:gameloader~GameLoader#recentGamesLoaded}.
     */
    pickGameToLoad() {
        var enabledGamesArr = Array.from(MainMenuManager.enabledGames.values());
        enabledGamesArr = enabledGamesArr.filter((game) => !this.#recentGamesLoaded.includes(game));
        var gameToLoad = enabledGamesArr[Math.floor(Math.random() * enabledGamesArr.length)];
        if (DEBUG_TEST !== "") {
            if (DEBUG_TEST === "sequential"){
                document.body.onkeyup = function(event){
                    if (event.key === "1") {
                        this.#debug_index++;
                        transition(true, function(){});
                    }
                }
                gameToLoad = Object.keys(this.#gamesList)[this.#debug_index];
                this.#debug_index++;
                if (this.#debug_index > Object.keys(this.#gamesList).length) {
                    this.#debug_index = 0;
                }
            } else {
                gameToLoad = DEBUG_TEST;
            }
            console.log("DEBUG TESTING: " + gameToLoad + " - " + this.#gamesList[gameToLoad]);
        } else {
            this.#recentGamesLoaded.push(gameToLoad);
            if (this.#recentGamesLoaded.length >= 4) {
                this.#recentGamesLoaded.shift();
            }
        }
        return gameToLoad;
    }

    /**
     * Sets the iframe with ID “game” (in index.html) to point to the new game’s .html.
     * @param {string} gameToLoad The name of the game’s folder to use.
     * @alias module:gameloader~GameLoader#loadGameHTML
     * @private
     */
    #loadGameHTML(gameToLoad){
        let gameURL = "./jam-version-assets/games/" + gameToLoad + "/" + this.#gamesList[gameToLoad];
        document.getElementById("game").src = gameURL;
        
        /**
         * String of the game ID (the game’s folder name) to load. Set in {@link module:gameloader~GameLoader#loadGameHTML|loadGameHTML}. Primarily meant for debugging.
         */
        this.currGame = gameToLoad;
    }
    /**
     * Set the source of the iframe with ID “game” (in index.html) to “about:blank” for resetting purposes.
     * Called only in {@link module:gameloader~GameLoader#animateTransition}.
     * @alias module:gameloader~GameLoader#clearGameHTML
     * @private
     */
    #clearGameHTML() {
        document.getElementById("game").src = "about:blank";
        document.getElementById("game").contentWindow.location.href = "about:blank";
    }

    /**
     *
     * Called by {@link MicrogameJam#gameStarted}. Hides the timer and game and sets {@link GameLoader#gameLoaded} to true.
     */
    gameStarted(){
        document.getElementById("game").removeAttribute("hidden");
        document.getElementById("timer").removeAttribute("hidden");
        this.#gameLoaded = true;
    }

    /**
     * Read-only property for {@link module:gameloader~GameLoader#gameLoaded}. Used by [input.js]{@link module:input}.
     *
     * @readonly
     */
    get inGame() {
        return this.#gameLoaded;
    }

    /**
     * Called by {@link MicrogameJam#update}. Only calls {@link module:picointerface~PicoInterface#picoUpdate} if the current game is running PICO-8.
     * @todo Should we call this.picoInterface.picoUpdate(this.picoInterface)???
     */
    loadUpdate() {
        if (PicoInterface.isPicoRunning()){
            this.picoInterface.picoUpdate();
        }
    }

    /**
     * Callback initialized in {@link module:gameloader~GameLoader#setupGameLoader|setupGameLoader}. 
     * Fiddles with Unity and PICO-8 games if detected (initializes {@link module:picointerface} if detected), and tries to fix their behavior to work correctly.
     * @private
     * @alias module:gameloader~GameLoader#iframeLoaded
     */
    #iframeLoaded() {
        // For Unity Exports specifically (minimal Unity HTML templates work good enough, except for when it adds margin):
        document.getElementById("game").contentDocument.body.style.margin = "0";
        if (PicoInterface.isPicoRunning()){
            this.picoInterface = new PicoInterface();
            this.picoInterface.interfaceWithPico();
        }
    }

    /**
     * A private reference for the current game we’re going to load throughout this.animateTransition().
     * Set using {@link module:gameloader~GameLoader#pickGameToLoad|pickGameToLoad}.
     * Used in {@link module:gameloader~GameLoader#loadGameHTML|loadGameHTML}.
     */
    #gameToLoad;

    /**
     * Read-only accessor for {@link module:gameloader~GameLoader#gameToLoad}.
     * @readonly
     */
    get game() {
        return this.#gameToLoad;
    }

    // ANIMATIONS
    // -----------------------------------------------------------------------

    /**
     * Special function called by {@link module:gameloader~GameLoader#animateTransition|animateTransition} for animating a complete loss of the game (1 or 0 lives remaining).
     * Should probably be merged with animateTransition at some point?
     * Uses {@link module:animationmanager} to do custom loss behavior (like removing lives, and exiting back to the home screen on a finished game).
     * Other than that, it mostly acts like exactly how {@link module:gameloader~GameLoader#animateTransition|animateTransition} would play out, if allowed to continue.
     */
    loseGameTransition() {
        var mainMenuDraw = false;
        this.setUpLifeCounter(1, true);
        var internalClock = performance.now();
        var gamesWon = this.#totalGamesPlayed - 3;
        GlobalAnimManager.playKeyframedAnimation("CCSSGLOBALloseAnimation", {
            shouldLoop: function(timestamp, animationObj) {
                if (!mainMenuDraw && (timestamp - internalClock) > 600){
                    mainMenuDraw = true;
                    // Unload the iframe, load the main menu:
                    document.getElementById("game").src = "about:blank";
                    document.getElementById("menu").removeAttribute("hidden");

                    document.getElementById("game-over-text").innerText = `You won ${gamesWon} games.`;

                    // Set up game over screen:
                    document.getElementById("game-over").removeAttribute("hidden");
                    GlobalAnimManager.playKeyframedAnimation("CCSSGLOBALhideMain", {keepAnims: true});
                    document.getElementById("backButton").style.transform = "translate(577px, -81px)";
                    document.getElementById("backButton").style.transformOrigin = "278% 0%";

                    document.getElementById("backButton").onclick = () => {
                        GlobalAudioManager.stop("endTheme");
                        GlobalAudioManager.play("theme", this.masterVolume * 0.2, false, true);
                        GlobalAnimManager.playKeyframedAnimation("CCSSGLOBALgameoverTomain", {
                            onFinish: function(){
                                document.getElementById("game-over").setAttribute("hidden", "");
                                MainMenuManager.resetMenuInputs();
                            }
                        });
                        document.getElementById("backButton").style.transform = "";
                        document.getElementById("backButton").style.transformOrigin = "";
                    };
                }
                return false;
            }.bind(this),
            onFinish: function() {
                document.getElementById("loseTransition").setAttribute("hidden", "");
                document.getElementById("transitionContainer").setAttribute("hidden", "");
                this.removeLives(1, true);
                GlobalAudioManager.play("endTheme", this.masterVolume * 0.2, false, true);
                MainMenuManager.isInMenu = true;
                MainMenuManager.resetMenuInputs();
            }.bind(this)
        });
        // Reset the total number of games played, but don't reset recentGamesLoaded.
        this.#totalGamesPlayed = 0;
    }

    /**
     * Gets everything ready to animate (like hiding the timer, showing the transition div, etc.)
     * Prepares the life counter (calling this.setupLifeCounter()).
     * Then uses GlobalAnimationManager.playKeyframedAnimation using all these settings.
     * Sets animation to loop until the game is loaded, or unless it has specific configurations in this.#gamesConfig.
     * Once the animation is configured to play, runs this.#loadGameHTML() (should probably just be moved to this.transition()? Although this would break this.loseGameTransition() behavior).

     * @param {string} transitionName The name to use in the saved GlobalAnimationManager when calling {@link module:animationmanager~CCSSGlobalAnimation#playKeyframedAnimation}.
     */
    animateTransition(transitionName) {
        GlobalAnimManager.stopAllAnimations();
        document.getElementById("timer").setAttribute("hidden", "");
        document.getElementById("transitionContainer").removeAttribute("hidden");
        document.getElementById(transitionName + "Transition").removeAttribute("hidden");

        var numLives = GameInterface.getLives();
        if (transitionName === "lose") {
            // This is used purely for animation, so if we've lost a life, we add one to show the losing animation.
            numLives++;
        }
        
        if ((transitionName === "lose" && numLives === 1) || numLives <= 0) {
            this.loseGameTransition();
            this.#gameToLoad = null;
            return;
        } else {
            this.#gameToLoad = this.pickGameToLoad();
        }

        var playTransitionPriorLoaded = false;

        this.setUpLifeCounter(numLives, transitionName === "lose");

        GlobalAnimManager.playKeyframedAnimation("CCSSGLOBAL" + transitionName + "Animation", {
            shouldLoop: function(timestamp, animationObj){
                // Should we load when we're looping? If yes, we have to actually wait until we're looping.
                if (this.#gamesConfig["play-transition-prior"].includes(this.#gameToLoad) && !playTransitionPriorLoaded && "loop" in animationObj.timeline.get(animationObj.currKeyframePlaying)) {
                    playTransitionPriorLoaded = true;
                    this.#loadGameHTML(this.#gameToLoad);
                }
                // Loop while our game isn't ready to start.
                return this.#gameLoaded === false; 
            }.bind(this),
            onFinish: function () {
                document.getElementById(transitionName + "Transition").setAttribute("hidden", "");
                document.getElementById("transitionContainer").setAttribute("hidden", "");
                this.removeLives(numLives, transitionName === "lose");
            }.bind(this)
        });

        if (!(this.#gamesConfig["play-transition-prior"].includes(this.#gameToLoad))) {
            this.#loadGameHTML(this.#gameToLoad);
        } else {
            this.#clearGameHTML();
        }
    }

    /**
     * Show the number intactLifeDiv s (setup in config.ini) based on numLives. And play a loseLife animation with {@link module:animationmanager} if lostLife is true. 
     * 
     * @param {number} numLives How many lives we have left
     * @param {boolean} lostLife Whether or not one of those lives will be lost.
     */
    setUpLifeCounter(numLives, lostLife) {
        for (var i = 0; i < numLives; i++) {
            document.getElementById("intactLifeDiv" + ((i > 0)? (i - 1) : "")).classList.add("active-lives");
        }

        var lostLifeDiv = document.getElementById("lostLifeDiv");
        var currPar = lostLifeDiv.parentNode;
        lostLifeDiv.parentNode.removeChild(lostLifeDiv);
        
        if (lostLife) {
            document.getElementById("intact-life" + ((numLives > 1) ? (numLives - 2) : "")).style.display = "none";

            var div = document.getElementById("intactLifeDiv" + ((numLives > 1) ? (numLives - 2) : ""));
            div.appendChild(lostLifeDiv);
            
            // You can manually set delays in the CCSS animation itself:
            GlobalAnimManager.playKeyframedAnimation("CCSSGLOBALloseLife", {
                keepAnims: true
            });
        } else {
            currPar.appendChild(lostLifeDiv);
        }
    }

    /**
     * Called by {@link module:gameloader~GameLoader#animationTransition} to tweak CSS settings for the intactLifeDiv s to remove them if they’ve been lost. And to hide them after the animation has finished.
     * @param {number} numLives How many lives we have left 
     * @param {boolean} lostLife Whether or not one of those lives will be lost.
     */
    removeLives(numLives, lostLife) {
        document.querySelectorAll(".active-lives").forEach(function(element){
            element.classList.remove("active-lives");
        });

        if (lostLife){
            document.getElementById("intact-life" + ((numLives > 1) ? (numLives - 2) : "")).style.display = "inherit";
        }
    }
};

var GlobalGameLoader = new GameLoader();

export default GlobalGameLoader;