import {Selectable, MenuVectorField, MenuVector} from "./menulib.js";
import GlobalInputManager, {MicrogameKeyboard} from "../input.js";
import GlobalGameLoader from "../../gameloader.js";
/**
* For managing options (the "options" button in the menu.)
* @file
*/
/**
* For managing options (the "options" button in the menu.)
* @module optionsmanager
*/
/**
* A list of games that we can select and do options for.
* @extends module:menulib~Selectable
*/
class GameList extends Selectable {
/**
* The current game selected.
* @type {number}
*/
#selected = 0;
/**
* A list of options to select from.
* @type {{field: MenuVectorField, element: HTMLElement, selectables: Array.<Selectable>}}
*/
#optionFields = [];
constructor(baseElement) {
super(baseElement);
for (var i = 0; i < this.element.children.length; i++) {
var child = this.element.children[i];
var options = child.querySelector(".game-options");
var selectables = Selectable.generateSelectablesArr(options, new MenuVector(0, 0), "isSelectable");
this.#optionFields.push({field: new MenuVectorField(selectables, 0), element: options, selectables: selectables});
}
this.#optionsSubSelect = false;
this.#optionsPick = -1;
}
/**
* Are we in the sub-selection menu for a given option?
* @type {boolean}
*/
#optionsSubSelect = false;
/**
* What set of options have we currently selected?
* @type {number}
*/
#optionsPick = -1;
/**
* Given an input direction (with the assumption that we are currently in the options menu), select a given option from either the list of options by game, or the sub-list of options for a game.
* @param {module:menulib~MenuVector} direction
* @param {module:menumanager~MicrogameJamMenuInputReader} inputReader
* @returns {?boolean} Did we land on a valid element and select it?
*/
optionsSelect(direction, inputReader){
if (direction.x !== 0 && this.#optionFields[this.#selected].selectables[this.#optionsPick].element.type === "range") {
var element = this.#optionFields[this.#selected].selectables[this.#optionsPick].element;
var volumeVal = parseInt(element.value);
element.value = (volumeVal + (direction.x * 1));
element.dispatchEvent(new Event("input"));
return;
}
var pick = this.#optionFields[this.#selected].field.getFromDir(direction);
if (pick === -1) {
if (direction.y === -1) {
this.#optionsSubSelect = false;
this.#optionsPick = -1;
}
if (direction.y === 1) {
this.#optionsSubSelect = false;
if (this.#selected < this.element.children.length - 1) {
this.#selected++;
}
this.#optionsPick = -1;
}
if (direction.x === -1) {
inputReader.setElement(0);
return true;
}
} else {
this.#optionsPick = pick;
}
}
/**
* Pick an element to select from a direction.
* @param {module:menulib~MenuVector} direction
* @param {module:menumanager~MicrogameJamMenuInputReader} inputReader
*/
selectElement(direction, inputReader){
if (this.#optionsSubSelect) {
if (this.optionsSelect(direction, inputReader)) {
return;
}
} else {
if (direction.x === -1) {
inputReader.setElement(0);
return;
}
this.clearSelect();
if (direction.y === 1 && this.#selected < this.element.children.length) {
if (this.element.children[this.#selected].className === "active") {
this.#optionsSubSelect = true;
this.#optionsPick = 0;
this.#optionFields[this.#selected].field.currPos = 0;
} else if (this.#selected < this.element.children.length - 1) {
this.#selected++;
}
}
if (direction.y === -1 && this.#selected > 0) {
if (this.element.children[this.#selected - 1].className === "active") {
this.#optionsSubSelect = true;
this.#optionsPick = this.#optionFields[this.#selected - 1].selectables.length - 1;
this.#optionFields[this.#selected - 1].field.currPos = this.#optionsPick;
}
this.#selected--;
}
}
this.select();
}
/**
* Actually hover over the selected element.
* Called when this element is first selected (and gets overrided by {@link module:optionsmanager~GameList#selectElement} for subsequent calls with the arrow keys).
*/
select() {
if (this.#optionsSubSelect) {
this.#optionFields[this.#selected].selectables[this.#optionsPick].element.classList.add("hover");
} else {
var selected = this.element.children[this.#selected];
selected.classList.add("hover");
var selectedPos = selected.offsetTop - this.element.scrollTop;
while (selectedPos + selected.offsetHeight > this.element.offsetHeight) {
this.element.scrollTop += selected.offsetHeight;
selectedPos = selected.offsetTop - this.element.scrollTop;
}
while (selectedPos < 0) {
this.element.scrollTop -= selected.offsetHeight;
selectedPos = selected.offsetTop - this.element.scrollTop;
}
}
}
/**
* Click over the selected element.
*/
click() {
if (this.#optionsSubSelect) {
this.#optionFields[this.#selected].selectables[this.#optionsPick].element.click();
} else {
this.element.children[this.#selected].click();
if (this.#selected === this.element.children.length - 1) {
// Because if you click on the bottom, it doesn't scroll to the expanded options. This is a cheap fix.
var scrollTo = setInterval(function(){
this.element.scrollTo(0, this.element.scrollHeight + 200);
if (this.element.children[this.#selected].scrollHeight > 240 || this.#selected !== this.element.children.length - 1) {
clearInterval(scrollTo);
}
}.bind(this), 1);
}
}
}
/**
* Clear the currently selected element.
*/
clearSelect() {
if (this.#optionsSubSelect) {
this.#optionFields[this.#selected].selectables[this.#optionsPick].element.classList.remove("hover");
} else {
this.element.children[this.#selected].classList.remove("hover");
}
}
}
/**
* The global manager for options.
*/
export class OptionsManager {
/**
* Currently selected option.
*/
#currentOption = "all";
/**
* Reference to MainMenuManager.
* @type {module:menumanager~MainMenuManager}
*/
#MainMenuManager;
/**
* Set of games to allow play for.
* @type {Set.<string>}
*/
#enabledGames = new Set();
/**
* The currently selected options element.
* @type {HTMLElement}
*/
#optionsSelect;
/**
* The list of options from localStorage. The keys are the folder IDs of games.
* @type {Object.<string, Map|string>}
*/
#optionsStorage;
#onVolume;
/**
* The internal callback for when the game's volume is set. Can only be set (not gotten)
*/
set onVolume(val) {
this.#onVolume = val;
val(parseInt(localStorage.getItem("volume"))/100);
}
/**
* Constructs an options storage HTML div using localStorage for previously stored options.
* This is literally where all the HTML is constructed, and there's a lot of it.
* @constructs OptionsManager
* @param {module:menumanager~MainMenuManager} MainMenuManager
* @todo This could probably be pre-compiled.
*/
constructor(MainMenuManager) {
this.#optionsStorage = localStorage.getItem("options");
if (this.#optionsStorage === null) {
this.#optionsStorage = {};
} else {
// From https://stackoverflow.com/questions/29085197/how-do-you-json-stringify-an-es6-map
this.#optionsStorage = JSON.parse(this.#optionsStorage, (key, value) => {
if (typeof value === "object" && value !== null) {
if (value.dataType === "Map") {
return new Map(value.value);
}
}
return value;
});
for (var game in this.#optionsStorage) {
var gameOption = this.#optionsStorage[game]
for (var dir in gameOption.dir) {
GlobalInputManager.clearBindings(game, dir);
GlobalInputManager.setBindingFromOption(game, dir, gameOption.dir[dir]);
}
}
}
this.#MainMenuManager = MainMenuManager;
this.#optionsSelect = document.getElementById("options-select-games");
var gameNames = {all : "Microgame Settings (All Games)", ... GlobalGameLoader.gameNames};
Object.keys(gameNames).forEach((game) => {
if (!(game in this.#optionsStorage)) {
this.#optionsStorage[game] = {
enabled: true
};
}
var div = document.createElement("div");
var gameName = gameNames[game];
var gameSelectDiv = document.createElement("div");
gameSelectDiv.className = "game-select";
if (game !== "all"){
var check = document.createElement("input");
check.type = "checkbox";
check.id = game + "enable";
check.checked = this.#optionsStorage[game].enabled;
check.name = game + "enable";
gameSelectDiv.appendChild(check);
check.oninput = this.updateEnabled.bind(this, game);
if (this.#optionsStorage[game].enabled) {
this.#enabledGames.add(game);
}
}
var label = document.createElement("label");
label.innerText = gameName;
// We want to be able to click each game to set individual settings.
// label.htmlFor = game + "enable";
gameSelectDiv.appendChild(label);
div.appendChild(gameSelectDiv);
div.id = "options-select-games-" + game;
div.onclick = this.#swapToOptions.bind(this, game);
var optionsDiv = document.createElement("div");
optionsDiv.id = "game-options-" + game;
optionsDiv.className = "game-options";
if (game !== "all"){
var enabledP = document.createElement("p");
enabledP.className = "game-enable";
enabledP.name = "game-enable";
var enabledCheck = document.createElement("input");
enabledCheck.type = "checkbox";
enabledCheck.checked = this.#optionsStorage[game].enabled;
enabledCheck.id = game + "-options-enable";
enabledCheck.oninput = this.updateEnabled.bind(this, game);
var enabledLabel = document.createElement("label");
enabledLabel.innerText = "Enabled";
enabledLabel.htmlFor = game + "-options-enable";
enabledP.appendChild(enabledCheck);
enabledP.appendChild(enabledLabel);
optionsDiv.appendChild(enabledP);
}
var remapDiv = document.createElement("div");
remapDiv.className = "remap-options";
var dirs = ["Up", "Right", "Down", "Left", "Space"];
dirs.forEach(function(d){
var direction = document.createElement("div");
direction.className = "remap-" + d;
var text = document.createElement("p");
text.innerText = d + " Bindings:";
direction.appendChild(text);
var bindButtonP = document.createElement("p");
var bindButton = document.createElement("button");
var bindingName = "Arrow" + d;
if (d === "Space") {
bindingName = " ";
}
bindButton.onclick = this.updateBinding.bind(this, game, bindingName);
var bindButtonText = document.createElement("div");
bindButtonText.className = "remap-button-text";
var strings = GlobalInputManager.getBindingsStrings(game);
if (d.toLowerCase() in strings) {
bindButtonText.innerText = strings[d.toLowerCase()];
}
bindButton.appendChild(bindButtonText);
var bindBackground = document.createElement("div");
bindBackground.className = "remap-button-background";
var bindMaskSize = Math.floor(Math.random() * 100) + 100;
bindBackground.style.maskSize = bindMaskSize + "%";
bindBackground.style.maskPosition = Math.floor(Math.random() * 100) + "% " + Math.floor(Math.random() * 100) + "%";
bindButton.appendChild(bindBackground);
bindButtonP.className = "bind-button";
bindButtonP.appendChild(bindButton);
direction.appendChild(bindButtonP);
var clearButtonP = document.createElement("p");
var clearButton = document.createElement("button");
clearButton.onclick = this.clearBindButton.bind(this, game, bindingName);
clearButtonP.className = "clear-button";
var clearButtonText = document.createElement("div");
clearButtonText.className = "remap-button-text";
if (bindButtonText.innerText.length === 0) {
clearButtonText.innerText = "Reset";
} else {
clearButtonText.innerText = "Clear";
}
clearButton.appendChild(clearButtonText);
var clearButtonBackground = document.createElement("div");
clearButtonBackground.className = "remap-button-background";
var clearMaskSize = Math.floor(Math.random() * 100) + 100;
clearButtonBackground.style.maskSize = clearMaskSize + "%";
clearButtonBackground.style.maskPosition = Math.floor(Math.random() * 100) + "% " + Math.floor(Math.random() * 100) + "%";
clearButton.appendChild(clearButtonBackground);
clearButtonP.appendChild(clearButton);
direction.appendChild(clearButtonP);
remapDiv.appendChild(direction);
}, this);
optionsDiv.appendChild(remapDiv);
div.appendChild(optionsDiv);
this.#optionsSelect.appendChild(div);
}, this);
// We can't add directly to innerHTML because it'll mess with the events.
document.getElementById("game-options-all").insertAdjacentHTML("afterbegin", `
<div style="float: left; height: 92px; width: 50px; margin-left: 10px;" id="game-options-all-volume">
<p style="width: 170px; text-align: center;">Main Menu Volume</p>
<input id="options-volume" type="range" min="1" max="100" value="100"/>
</div>`);
if (localStorage.getItem("volume") === null) {
localStorage.setItem("volume", 100);
}
document.getElementById("options-volume").value = parseInt(localStorage.getItem("volume"));
document.getElementById("options-volume").addEventListener("input", function() {
this.#onVolume(parseInt(document.getElementById("options-volume").value)/100);
localStorage.setItem("volume", document.getElementById("options-volume").value);
}.bind(this));
document.getElementById("options-select-games-all").className = "active";
this.optionsSave();
}
/**
* Save the options to localStorage.
*/
optionsSave() {
// From https://stackoverflow.com/questions/29085197/how-do-you-json-stringify-an-es6-map
localStorage.setItem("options", JSON.stringify(this.#optionsStorage, (key, value) => {
if (value instanceof Map) {
return {
dataType: "Map",
value: [...value]
}
} else if (key === "type" && typeof value === "function") {
return value.name
} else {
return value;
}
}));
}
/**
* Update whether or not a game has been enabled.
* @param {string} game Game ID.
* @param {Event} event Event for the checkbox.
*/
updateEnabled(game, event) {
if (event.target.checked === false && this.#enabledGames.size <= 4) {
event.target.checked = true;
return;
}
var enabled = event.target.checked;
if (enabled) {
this.#enabledGames.add(game);
} else {
this.#enabledGames.delete(game);
}
this.#optionsStorage[game].enabled = enabled;
document.getElementById(game + "enable").checked = event.target.checked;
document.getElementById(game + "-options-enable").checked = event.target.checked;
this.optionsSave();
}
/**
* Did we press a button to bind a key?
*/
#selectPressed = false;
/**
* Update a binding when we recieve a key press from {@link module:input~MicrogameInputManager}
* @param {HTMLElement} target The element we're displaying the key pressed on.
* @param {string} game Game ID
* @param {string} bindingName The direction we're binding to. Usually ArrowUp, ArrowDown, ArrowLeft, ArrowRight, or Space.
* @param {{control: iter.value, type: module:input~MicrogameInput}} bindingPressed
*/
updateBindingCapture(target, game, bindingName, bindingPressed) {
if (!MicrogameKeyboard.allKeysDown.has(" ")) {
this.#selectPressed = false;
}
if (!this.#selectPressed && bindingPressed !== null){
var has = GlobalInputManager.hasBinding(game, bindingPressed);
if (has.has) {
// If the binding exists anywhere else in the control scheme, we don't update it:
target.innerText = (bindingPressed.control === " " ? "Space" : bindingPressed.control) + " Already Bound";
setTimeout(() => {
target.innerText = GlobalInputManager.getBindingsStringsByBindingName(game)[bindingName];
}, 1000);
this.#bindingTarget = null;
return true;
}
GlobalInputManager.addBinding(game, bindingName, bindingPressed);
target.innerText = GlobalInputManager.getBindingsStringsByBindingName(game)[bindingName];
if (!("dir" in this.#optionsStorage[game])) {
this.#optionsStorage[game].dir = {
...GlobalInputManager.getAllBindings(game)
};
}
this.#MainMenuManager.pauseInputs(100);
this.#optionsStorage[game].dir[bindingName].set(bindingPressed.control, bindingPressed);
this.optionsSave();
this.#bindingTarget = null;
return true;
}
}
/**
* Stop trying to find a key to bind for.
*/
stopBinding() {
if (this.#bindingTarget !== null){
this.#bindingTarget.element.innerText = GlobalInputManager.getBindingsStringsByBindingName(this.#bindingTarget.gameName)[this.#bindingTarget.bindingName];
GlobalInputManager.cancelCaptureInput();
this.#bindingTarget = null;
}
}
/**
* The thing we're currently trying to bind.
* @type {{element: HTMLElement, bindingName: string, gameName: string}}
*/
#bindingTarget = null;
/**
* Start binding a game's control.
* @param {string} game Game ID.
* @param {string} bindingName Binding ID.
* @param {Event} event The event that started this action.
*/
updateBinding(game, bindingName, event) {
if (this.#bindingTarget !== null) {
this.stopBinding();
}
var target = event.currentTarget.querySelector(".remap-button-text");
// Additionally, if we're already capturing a binding, don't update our behavior.
if (target.innerText.includes("Already Bound")){
return;
}
target.innerText = "<<Press>>";
this.#bindingTarget = {element: target, bindingName: bindingName, gameName: game};
this.#selectPressed = true;
GlobalInputManager.captureNextInput(this.updateBindingCapture.bind(this, target, game, bindingName));
event.currentTarget.parentElement.parentElement.querySelector(".clear-button .remap-button-text").innerText = "Clear";
}
/**
* Called when the clear button gets pressed. Resets all the relevant bindings.
* @param {string} game Game ID
* @param {string} bindingName Binding ID
* @param {Event} ev The event that called the push to the clear button.
*/
clearBindButton(game, bindingName, ev) {
if (this.#bindingTarget !== null) {
this.stopBinding();
}
if (!("dir" in this.#optionsStorage[game])) {
this.#optionsStorage[game].dir = {
...GlobalInputManager.getAllBindings(game)
};
}
if (ev.currentTarget.querySelector(".remap-button-text").innerText === "Reset") {
this.resetBindings(game, bindingName);
ev.currentTarget.parentElement.parentElement.querySelector(".bind-button .remap-button-text").innerText = GlobalInputManager.getBindingsStringsByBindingName(game)[bindingName];
ev.currentTarget.querySelector(".remap-button-text").innerText = "Clear";
} else {
ev.currentTarget.parentElement.parentElement.querySelector(".bind-button .remap-button-text").innerText = "";
this.clearBindings(game, bindingName);
this.#optionsStorage[game].dir[bindingName].clear();
ev.currentTarget.querySelector(".remap-button-text").innerText = "Reset";
}
this.optionsSave();
}
/**
* Make some calls to {@link module:input~MicrogameInputManager} to reset our current bindings. Update {@link module:optionsmanager.OptionsManager#optionsStorage}.
* @param {string} gameName Game ID
* @param {string} bindingName Binding ID
*/
resetBindings(gameName, bindingName) {
GlobalInputManager.resetBindings(gameName, bindingName);
this.#optionsStorage[gameName].dir[bindingName] = GlobalInputManager.getAllBindings(gameName)[bindingName];
}
/**
* Clear ALL bindings.
* @param {string} gameName Game ID
* @param {string} bindingName Binding ID
*/
clearBindings(gameName, bindingName) {
GlobalInputManager.clearBindings(gameName, bindingName);
this.#optionsStorage[gameName].dir[bindingName].clear();
}
/**
* Add our current options to the selectables list in {@link module:menumanager~MainMenuManager}.
*/
startManagingOptions() {
this.#MainMenuManager.addSelectable(new GameList(this.#optionsSelect));
}
/**
* Return {@link module:optionsmanager.OptionsManager#enabledGames}
* @readonly
*/
get enabledGames() {
return this.#enabledGames;
}
/**
* Select a game's options to swap to.
* @param {string} gameName Game ID.
* @alias module:optionsmanager.OptionsManager#swapToOptions
*/
#swapToOptions(gameName) {
if (this.#currentOption !== gameName) {
if (this.#bindingTarget !== null) {
this.stopBinding();
}
document.getElementById("options-select-games-" + this.#currentOption).classList.remove("active");
document.getElementById("options-select-games-" + gameName).classList.add("active");
this.#currentOption = gameName;
}
}
}