Source: lib/options/menumanager.js

import {OptionsManager} from "./optionsmanager.js"
import {Selectable, MenuVector, MenuVectorField} from "./menulib.js"
import GlobalAnimManager from "../animationmanager.js";
import iniReader from "../configloader.js";
var ini;
/**
 * All of the classes for running the main menu. Win and lose transitions are also created here.
 * @file
 */
/**
 * All of the classes for running the main menu. Win and lose transitions are also created here.
 * @module menumanager
 */

/**
 * Handles the creation of the main menu and transition elements.
 * Also handles actual control of the main menu elements.
 * @tutorial adding-games
 */
class ElementCreator {
    /**
     * 
     * @param {string} elementId ID of the element to get and create for.
     * @param {Object} iniObj Object of .ini file.
     * @param {string} className Class name of the elements to create.
     * @param {string} srcDir Source directory of elements to load from.
     * @todo All of the associated variables should be previate.
     * @constructs ElementCreator
     */
    constructor(elementId, iniObj, className, srcDir){
        /**
         * The element we're going to be creating our derived elements from (using {@link module:menumanager~ElementCreator#drawElements}).
         * @type {Element}
         */
        this._element = document.getElementById(elementId);
        /**
         * The ini object file we're creating elements from in {@link module:menumanager~ElementCreator#drawElements}.
         * @tutorial adding-games
         * @type {Object}
         */
        this._iniObj = iniObj;
        /**
         * The class name associated with the elements we'll be creating.
         * @type {string}
         */
        this._className = className;
        /**
         * The source directory to load assets from.
         * @type {string}
         */
        this._srcDir = srcDir;
        /**
         * Child elements that this class has created.
         * @type {Array.<Element>}
         */
        this.elements = [];
        
        if (this._srcDir !== "") {
            this._srcDir += "/";
        }
    }

    /**
     * Create elements from {@link module:menumanager~ElementCreator#_iniObj}
     */
    drawElements(){
        var elementsToSearch = Object.entries(this._iniObj);

        for (var i = 0; i < elementsToSearch.length; i++) {
            var elementName = elementsToSearch[i][0];
            var element = elementsToSearch[i][1];

            // If it's actually an element:
            if (typeof element === "object" && ("img" in element || "text" in element)) {
                var offset = [0, 0, 0];
                if ("offset" in element) {
                    offset = element.offset.replace(/\(|\)/, "").split(",");
                    offset.forEach(function(o, i){
                        offset[i] = parseInt(o);
                    });
                }
                var newElement;
                if ("img" in element) {
                    newElement = document.createElement("img");
                    newElement.src = "jam-version-assets/art/" + this._srcDir + element.img;
                    newElement.draggable = false;
                } else if ("text" in element) {
                    newElement = document.createElement("p");
                    newElement.innerHTML = markdown.render(element.text);
                }

                newElement.className = this._className;
                newElement.id = elementName;
                
                
                newElement.style = `position: absolute; left: ${offset[0]}px; top: ${offset[1]}px; z-index: ${offset[2]};`;

                if ("div" in element) {
                    var div = document.getElementById(element.div);
                    div ??= document.createElement("div");
                    div.id = element.div;
                    div.className = this._className;
                    // So we can apply z-index:
                    div.style = `position: absolute; z-index: ${offset[2]}; top: 0; left: 0;`;
                    div.appendChild(newElement);

                    if (div.parentElement === null) {
                        this._element.appendChild(div);
                    }
                } else {
                    this._element.appendChild(newElement);
                }
                
                this.elements.push(newElement);

                var timesToDupe = 0;

                if ("count" in element) {
                    timesToDupe = parseInt(element.count) - 1;
                }

                var id = newElement.id;

                for (var j = 0; j < timesToDupe; j++){
                    var dupe = newElement.cloneNode();
                    dupe.id = id + j;
                    if ("individualDiv" in element) {
                        var dupeDiv = newElement.parentNode.cloneNode();
                        var dupeDivId = newElement.parentNode.id;
                        dupeDiv.id = dupeDivId + j;
                        newElement.parentNode.parentNode.appendChild(dupeDiv);
                        dupeDiv.appendChild(dupe);
                    } else {
                        newElement.parentNode.appendChild(dupe);
                    }
                    this.elements.push(dupe);
                }
            }
        }
    }
}

/**
 * The full main menu object for running the whole main menu system.
 */
class MicrogameJamMenu {
    /**
     * The current menu we've loaded
     * @type {string}
     */
    #currMenu = "main";
    /**
     * The menu we're transitioning to.
     * @type {string}
     */
    #destMenu;
    /**
     * @type {MicrogameJamMenuInputReader}
     */
    #inputReader;
    /**
     * @type {OptionsManager}
     */
    #optionsManager;

    /**
     * Promise for waiting for ini to be loaded, then call {@link module:menumanager~MicrogameJamMenu#setUp}.
     * @type {Promise}
     */
    #setUpPromise;

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

    /**
     * Setter for a callback when the options' volume slider is set.
     * Set in {@link MicrogameJam} constructor.
     * @param {function} value
     */
    set onVolume(value) {
        this.#optionsManager.onVolume = value;
    }

    /**
     * Returns {@link module:menumanager~MicrogameJamMenu#setUpPromise}.
     * @readonly
     */
    get onSetup() {
        return this.#setUpPromise;
    }

    /**
     * Returns {@link module:optionsmanager.OptionsManager#enabledGames}.
     * @readonly
     */
    get enabledGames() {
        return this.#optionsManager.enabledGames;
    }

    /**
     * Initialize the main menu ({@link module:menumanager~MicrogameJamMenu#initMainMenu}) and initialize menu transitions ({@link module:menumanager~MicrogameJamMenu#initTransitions}).
     * @alias module:menumanager~MicrogameJamMenu#setUp
     */
    #setUp(){
        if (!(ini["Transitions"].debug === "win" || ini["Transitions"].debug === "lose")){
            this.#initMainMenu();
        }
        this.#initTransitions();

        this.#inputReader = new MicrogameJamMenuInputReader();
        this.#optionsManager = new OptionsManager(this);
    }

    /**
     * Pause recieving input for a bit. Used to avoid creating bugs with spamming inputs. Call {@link module:menumanager~MicrogameJamMenuInputReader#pauseInputs}.
     * @param {number} ms Time in miliseconds to pause inputs for.
     */
    pauseInputs(ms){
        this.#inputReader.pauseInputs(ms);
    }

    /**
     * Reset recieving input and menu selection. Call {@link module:menumanager~MicrogameJamMenuInputReader#resetMenuInputs}.
     */
    resetMenuInputs() {
        this.#inputReader.resetMenuInputs();
    }

    /**
     * Call {@link module:menumanager~MicrogameJamMenuInputReader#addSelectable}.
     * @param {Selectable} selectable 
     */
    addSelectable(selectable) {
        this.#inputReader.addSelectable(selectable);
    }
    
    /**
     * For drawing the credits.
     */
    #textY = 0;
    /**
     * Have the credits been drawn?
     */
    #creditsInputsDrawn = false;

    /**
     * Mapping menu states to various functions.
     * shouldLoop returns a boolean as to whether or not the menu's transition animation should loop.
     * backCallback is the function to call when you hit the "back" button.
     * onFinish is what to call when the menu's transition animation is finished.
     * @type {Object.<string, {shouldLoop: function, backCallback: function, onFinish: function}>}
     */
    #menuMapping = {
        "credits": {
            shouldLoop: function(time, animationObj){
                if (!this.#creditsInputsDrawn && animationObj.currFrame.index === 3) {
                    this.#inputReader.resetMenuInputs();
                    if (this.#inputReader.selectableElements.length > 0){
                        this.#creditsInputsDrawn = true;
                    }
                }
                return this.#currMenu === "credits";
            },
            backCallback: this.transitionTo.bind(this, "main")
        },
        "options": {
            backCallback: this.transitionTo.bind(this, "main"),
            onFinish: function() {
                this.#optionsManager.startManagingOptions();
            }
        },
        "main": {
            onFinish: function(){
                if (this.#currMenu === "credits"){
                    this.#creditsInputsDrawn = false;
                    document.getElementById("credits-text").style.setProperty("--text-y", 0);
                    this.#textY = 0;
                    GlobalAnimManager.stopAllKeyframedAnimationOf(`CCSSGLOBALmainTocredits`);
                }
                this.#currMenu = "main";
            },
            shouldLoop: function(){
                if (this.#currMenu === "options") {
                    this.#optionsManager.stopBinding();
                }
                if (this.#currMenu === "credits"){
                    this.#textY -= 150;
                    document.getElementById("credits-text").style.setProperty("--text-y", this.#textY);
                }
                return false;
            }
        }
    };

    /**
     * Transition to a menu and call its associated CCSSGLOBAL{{@link module:menumanager~MicrogameJamMenu#currMenu}}To{menu}.
     * @param {string} menu Menu state to transition to.
     */
    transitionTo(menu){
        var animName = `CCSSGLOBAL${this.#currMenu}To${menu}`;

        if (menu !== "main"){
            document.getElementById("backButton").onclick = this.#menuMapping[menu].backCallback;
        }

        // Are we currently heading AWAY from the main menu, or are we currently at the main menu?
        if (this.#currMenu === "main" || menu === "main"){
            this.#destMenu = menu;
            
            // We don't want to set the main menu to immediately clear because we want to wait for transitions to play out.
            if (this.#destMenu !== "main"){
                this.#currMenu = menu;
            }

            // Reset animation:
            GlobalAnimManager.stopAllKeyframedAnimationOf(animName);
            var menuMapping = this.#menuMapping;
            var destMenu = this.#destMenu;
            GlobalAnimManager.playKeyframedAnimation(animName, {
                keepAnims: this.#destMenu !== "main",
                shouldLoop: function(time, animationObj) {
                    if ("shouldLoop" in menuMapping[destMenu]){
                        return menuMapping[destMenu].shouldLoop.call(this, time, animationObj);
                    } else {
                        return false;
                    }
                }.bind(this),
                onFinish: function(){
                    if ("onFinish" in menuMapping[destMenu]) {
                        menuMapping[destMenu].onFinish.call(this);
                    }
                    this.#inputReader.resetMenuInputs();
                }.bind(this)
            });
        }
    }

    /**
     * Setter for {@link module:MicrogameJamMenuInputReader#isInMenu}.
     */
    set isInMenu(val) {
        this.#inputReader.isInMenu = val;
    }

    /**
     * Initialize the main menu with a bunch of {@link module:menumanager~ElementCreator}.
     * @alias module:menumanager~MicrogameJamMenu#initMainMenu
     */
    #initMainMenu() {
        var mainMenu = new ElementCreator("menu", ini["Menu"], "menu-art", "");
        mainMenu.drawElements();
    
        var credits = new ElementCreator("menu", ini["Credits"], "credits", "");
        credits.drawElements();
    
        document.getElementById("creditsButton").onclick = this.transitionTo.bind(this, "credits");
        document.getElementById("optionsButton").onclick = this.transitionTo.bind(this, "options");

        document.getElementById("game-over").setAttribute("hidden", "");
    }

    /**
     * Initialize transitions (i.e., win and lose) using {@link module:menumanager~ElementCreator}
     */
    #initTransitions(){
        var defaultTransition = new ElementCreator("transitionContainer", ini["Transitions"], "transition-art", "transitions");
        defaultTransition.drawElements();
    
        var intactLives = new ElementCreator("transitionLives", ini["Transitions"]["Lives"], "lives-transition-art", "transitions");
        intactLives.drawElements();
    
        var lostLives = new ElementCreator("transitionLives", ini["Transitions"]["Lives"]["Lost"], "lost-lives-transition-art", "transitions");
        lostLives.drawElements();
    
        var winTransition = new ElementCreator("winTransition", ini["Transitions"]["Win"], "win-transition-art", "transitions/win");
        winTransition.drawElements();
    
        var loseTransition = new ElementCreator("loseTransition", ini["Transitions"]["Lose"], "lose-transition-art", "transitions/lose");
        loseTransition.drawElements();
    }
}

/**
 * Read inputs for the main menu.
 */
class MicrogameJamMenuInputReader {

    /**
     * List of elements that we can select from.
     * @type {Array.<Selectable>}
     */
    #selectableElements = [];

    constructor() {
        this.#setUpMenuInputs();
        document.body.addEventListener("keydown", this.#readMenuInputs.bind(this));
        document.body.addEventListener("mousemove", function(){
            document.body.style.cursor = "inherit";
            if (this.#selectedElement !== -1){
                this.#clearSelect();
                this.#selectedElement = -1;
            }
        }.bind(this));
    }

    /**
     * Buffer of selectables to add as things we can select.
     * Not added immediately so we can transition between menu states.
     */
    #selectablesToAdd = [];

    /**
     * Add a selectable to {@link module:menumanager~MicrogameJamMenuInputReader#selectablesToAdd}
     * @param {Selectable} selectable 
     */
    addSelectable(selectable) {
        this.#selectablesToAdd.push(selectable);
    }

    /**
     * Return {@link module:menumanager~MicrogameJamMenuInputReader#selectableElements}.
     * @readonly
     */
    get selectableElements(){
        return this.#selectableElements;
    }

    /**
     * The vector field we're using for the current menu state.
     * @type {MenuVectorField}
     */
    #selectableVectorField;

    /**
     * Called with every state change to a new menu state. Set up the current selectables and MenuVectorField from those selectables.
     * @alias module:menumanager~MicrogameJamMenuInputReader#setUpMenuInputs
     */
    #setUpMenuInputs() {
        var menu = document.getElementById("menu");
        this.#selectableElements = Selectable.generateSelectablesArr(menu);
        this.#selectableElements.push(...this.#selectablesToAdd);
        this.#selectablesToAdd = [];

        this.#selectableVectorField = new MenuVectorField(this.#selectableElements, 0);

        if (this.#selectedElement !== -1) {
            this.#selectedElement = 0;
            this.#selectElement(new MenuVector(0, 0));
        }
    }

    /**
     * Called on a menu state change. Reset inputs for a new set of selectables.
     * Calls {@link module:menumanager~MicrogameJamMenuInputReader#setUpMenuInputs} and {@link module:menumanager~MicrogameJamMenuInputReader#clearSelect}.
     */
    resetMenuInputs() {
        this.#clearSelect();
        this.#setUpMenuInputs();
    }

    /**
     * Clear selection of current selectables.
     * @alias module:menumanager~MicrogameJamMenuInputReader#clearSelect
     */
    #clearSelect() {
        this.#selectableElements.forEach(function(e){
            e.clearSelect();
        });
    }

    /**
     * Select an element given an index.
     * @param {number} index 
     */
    setElement(index) {
        this.#selectedElement = index;
        this.#selectableVectorField.currPos = this.#selectedElement;
        this.#selectableElements[this.#selectedElement].select();
    }

    /**
     * Mostly a wrapper for {@link module:menumanager~MenuVectorField#getFromDir}, but has some functionality for picking an element to highlight if one cannot be found.
     * @param {MenuVector} direction 
     */
    #selectElement(direction){
        if (this.#selectableElements.length === 0){
            return;
        }

        // Does the selected element want to override our controls?
        if (this.#selectableElements[this.#selectedElement].selectElement instanceof Function) {
            // Don't do anything else if the element is still considered "selected".
            this.#selectableElements[this.#selectedElement].selectElement(direction, this);
            return;
        }
        
        var pick = this.#selectableVectorField.getFromDir(direction);
        if (pick !== -1){
            this.#selectedElement = pick;
        } else if (this.#selectedElement === -1) {
            this.#selectedElement = 0;
        }

        
        this.#selectableElements[this.#selectedElement].select();
    }

    /**
     * The current element we've selected.
     * @type {number}
     */
    #selectedElement = -1;
    /**
     * Are we currently in the menu? If not, we're in Microgames.
     * @type {boolean}
     */
    isInMenu = true;
    /**
     * Some time in the future that we should be pausing input for. Set in {@link module:menumanager~MicrogameJamMenuInputReader#pauseInputs}.
     * @type {number}
     */
    #pauseInputTimer = -1;

    /**
     * Read an input and apply it to the menu. Called by onkeydown by the constructor of {@link module:menumanager~MicrogameJamMenuInputReader}.
     * @param {Event} ev 
     * @alias module:menumanager~MicrogameJamMenuInputReader#readMenuInputs
     */
    #readMenuInputs(ev) {
        if (this.isInMenu) {
            ev.preventDefault();
        } else {
            return;
        }
        if (this.#pauseInputTimer > 0) {
            if (this.#pauseInputTimer > performance.now()){
                this.#pauseInputTimer = -1;
            }
            return;
        }
        if (this.#selectedElement === -1) {
            document.body.style.cursor = "none";
            this.#selectedElement = 0;
            this.#selectableVectorField.currPos = 0;
            this.#selectElement(new MenuVector(0, 0));
            return;
        }
        if (ev.key === " ") {
            this.#selectableElements[this.#selectedElement].click();
            return;
        }
        this.#clearSelect();
        var dir = [0, 0];
        if (ev.key === "ArrowLeft") {
            dir[0] = -1;
        } else if (ev.key === "ArrowRight") {
            dir[0] = 1;
        }
        if (ev.key === "ArrowDown") {
            dir[1] = 1;
        } else if (ev.key === "ArrowUp") {
            dir[1] = -1;
        }
        this.#selectElement(new MenuVector(dir));
    }

    /**
     * Pause recieving inputs. Set {@link module:menumanager~MicrogameJamMenuInputReader#pauseInputTimer}.
     * @param {number} ms The duration of the input pause. 
     */
    pauseInputs(ms) {
        this.#pauseInputTimer = performance.now() + ms;
    }
}

var MainMenuManager = new MicrogameJamMenu();

export default MainMenuManager;