diff --git a/src/js/core/animation_frame.js b/src/js/core/animation_frame.js index 6aa629a5..03e4c69a 100644 --- a/src/js/core/animation_frame.js +++ b/src/js/core/animation_frame.js @@ -11,7 +11,9 @@ const resetDtMs = 16; export class AnimationFrame { constructor() { + /** @type {Signal<[number]>} */ this.frameEmitted = new Signal(); + /** @type {Signal<[number]>} */ this.bgFrameEmitted = new Signal(); this.lastTime = performance.now(); diff --git a/src/js/core/background_resources_loader.js b/src/js/core/background_resources_loader.js index 01e74479..0dcf8561 100644 --- a/src/js/core/background_resources_loader.js +++ b/src/js/core/background_resources_loader.js @@ -55,6 +55,7 @@ export class BackgroundResourcesLoader { this.mainMenuPromise = null; this.ingamePromise = null; + /** @type {Signal<[{ progress: number }]>} */ this.resourceStateChangedSignal = new Signal(); } diff --git a/src/js/core/click_detector.js b/src/js/core/click_detector.js index fb62f0f1..bfcb4117 100644 --- a/src/js/core/click_detector.js +++ b/src/js/core/click_detector.js @@ -83,16 +83,25 @@ export class ClickDetector { this.preventClick = preventClick; // Signals + /** @type {Signal<[Vector, TouchEvent | MouseEvent]>} */ this.click = new Signal(); + /** @type {Signal<[Vector, MouseEvent]>} */ this.rightClick = new Signal(); + /** @type {Signal<[TouchEvent | MouseEvent]>} */ this.touchstart = new Signal(); + /** @type {Signal<[TouchEvent | MouseEvent]>} */ this.touchmove = new Signal(); + /** @type {Signal<[TouchEvent | MouseEvent]>} */ this.touchend = new Signal(); + /** @type {Signal<[TouchEvent | MouseEvent]>} */ this.touchcancel = new Signal(); // Simple signals which just receive the touch position + /** @type {Signal<[number, number]>} */ this.touchstartSimple = new Signal(); + /** @type {Signal<[number, number]>} */ this.touchmoveSimple = new Signal(); + /** @type {Signal<[(TouchEvent | MouseEvent)?]>} */ this.touchendSimple = new Signal(); // Store time of touch start diff --git a/src/js/core/factory.js b/src/js/core/factory.ts similarity index 69% rename from src/js/core/factory.js rename to src/js/core/factory.ts index f2587f69..b30513b4 100644 --- a/src/js/core/factory.js +++ b/src/js/core/factory.ts @@ -3,21 +3,19 @@ import { createLogger } from "./logging"; const logger = createLogger("factory"); // simple factory pattern -export class Factory { - constructor(id) { - this.id = id; +export class Factory { + // Store array as well as dictionary, to speed up lookups + public entries: Class[] = []; + public entryIds: string[] = []; + public idToEntry: Record> = {}; - // Store array as well as dictionary, to speed up lookups - this.entries = []; - this.entryIds = []; - this.idToEntry = {}; - } + constructor(public id: string) {} getId() { return this.id; } - register(entry) { + register(entry: Class & { getId(): string }) { // Extract id const id = entry.getId(); assert(id, "Factory: Invalid id for class: " + entry); @@ -33,19 +31,15 @@ export class Factory { /** * Checks if a given id is registered - * @param {string} id - * @returns {boolean} */ - hasId(id) { + hasId(id: string): boolean { return !!this.idToEntry[id]; } /** * Finds an instance by a given id - * @param {string} id - * @returns {object} */ - findById(id) { + findById(id: string): Class { const entry = this.idToEntry[id]; if (!entry) { logger.error("Object with id", id, "is not registered on factory", this.id, "!"); @@ -57,25 +51,22 @@ export class Factory { /** * Returns all entries - * @returns {Array} */ - getEntries() { + getEntries(): Class[] { return this.entries; } /** * Returns all registered ids - * @returns {Array} */ - getAllIds() { + getAllIds(): string[] { return this.entryIds; } /** * Returns amount of stored entries - * @returns {number} */ - getNumEntries() { + getNumEntries(): number { return this.entries.length; } } diff --git a/src/js/core/global_registries.js b/src/js/core/global_registries.js deleted file mode 100644 index 723bf567..00000000 --- a/src/js/core/global_registries.js +++ /dev/null @@ -1,39 +0,0 @@ -import { SingletonFactory } from "./singleton_factory"; -import { Factory } from "./factory"; - -/** - * @typedef {import("../game/time/base_game_speed").BaseGameSpeed} BaseGameSpeed - * @typedef {import("../game/component").Component} Component - * @typedef {import("../game/base_item").BaseItem} BaseItem - * @typedef {import("../game/game_mode").GameMode} GameMode - * @typedef {import("../game/meta_building").MetaBuilding} MetaBuilding - - -// These factories are here to remove circular dependencies - -/** @type {SingletonFactoryTemplate} */ -export let gMetaBuildingRegistry = new SingletonFactory(); - -/** @type {Object.>>} */ -export let gBuildingsByCategory = null; - -/** @type {FactoryTemplate} */ -export let gComponentRegistry = new Factory("component"); - -/** @type {FactoryTemplate} */ -export let gGameModeRegistry = new Factory("gameMode"); - -/** @type {FactoryTemplate} */ -export let gGameSpeedRegistry = new Factory("gamespeed"); - -/** @type {FactoryTemplate} */ -export let gItemRegistry = new Factory("item"); - -// Helpers - -/** - * @param {Object.>>} buildings - */ -export function initBuildingsByCategory(buildings) { - gBuildingsByCategory = buildings; -} diff --git a/src/js/core/global_registries.ts b/src/js/core/global_registries.ts new file mode 100644 index 00000000..10780474 --- /dev/null +++ b/src/js/core/global_registries.ts @@ -0,0 +1,20 @@ +import type { BaseGameSpeed } from "../game/time/base_game_speed"; +import type { Component } from "../game/component"; +import type { BaseItem } from "../game/base_item"; +import type { GameMode } from "../game/game_mode"; +import type { MetaBuilding } from "../game/meta_building"; + +import { SingletonFactory } from "./singleton_factory"; +import { Factory } from "./factory"; + +// These factories are here to remove circular dependencies + +export const gMetaBuildingRegistry = new SingletonFactory("metaBuilding"); + +export const gComponentRegistry = new Factory("component"); + +export const gGameModeRegistry = new Factory("gameMode"); + +export const gGameSpeedRegistry = new Factory("gameSpeed"); + +export const gItemRegistry = new Factory("item"); diff --git a/src/js/core/input_distributor.js b/src/js/core/input_distributor.ts similarity index 75% rename from src/js/core/input_distributor.js rename to src/js/core/input_distributor.ts index be5440a9..b373164d 100644 --- a/src/js/core/input_distributor.js +++ b/src/js/core/input_distributor.ts @@ -1,7 +1,5 @@ -/* typehints:start */ -import { Application } from "../application"; -import { InputReceiver } from "./input_receiver"; -/* typehints:end */ +import type { Application } from "../application"; +import type { InputReceiver, ReceiverId } from "./input_receiver"; import { Signal, STOP_PROPAGATION } from "./signal"; import { createLogger } from "./logging"; @@ -10,47 +8,33 @@ import { arrayDeleteValue, fastArrayDeleteValue } from "./utils"; const logger = createLogger("input_distributor"); export class InputDistributor { + public recieverStack: InputReceiver[] = []; + public filters: ((arg: string) => boolean)[] = []; + /** - * - * @param {Application} app + * All keys which are currently down */ - constructor(app) { - this.app = app; - - /** @type {Array} */ - this.recieverStack = []; - - /** @type {Array} */ - this.filters = []; - - /** - * All keys which are currently down - */ - this.keysDown = new Set(); + public keysDown = new Set(); + constructor(public app: Application) { this.bindToEvents(); } /** * Attaches a new filter which can filter and reject events - * @param {function(any): boolean} filter */ - installFilter(filter) { + installFilter(filter: (arg: string) => boolean) { this.filters.push(filter); } /** * Removes an attached filter - * @param {function(any) : boolean} filter */ - dismountFilter(filter) { + dismountFilter(filter: (arg: string) => boolean) { fastArrayDeleteValue(this.filters, filter); } - /** - * @param {InputReceiver} reciever - */ - pushReciever(reciever) { + pushReciever(reciever: InputReceiver) { if (this.isRecieverAttached(reciever)) { assert(false, "Can not add reciever " + reciever.context + " twice"); logger.error("Can not add reciever", reciever.context, "twice"); @@ -66,10 +50,7 @@ export class InputDistributor { } } - /** - * @param {InputReceiver} reciever - */ - popReciever(reciever) { + popReciever(reciever: InputReceiver) { if (this.recieverStack.indexOf(reciever) < 0) { assert(false, "Can not pop reciever " + reciever.context + " since its not contained"); logger.error("Can not pop reciever", reciever.context, "since its not contained"); @@ -86,45 +67,29 @@ export class InputDistributor { arrayDeleteValue(this.recieverStack, reciever); } - /** - * @param {InputReceiver} reciever - */ - isRecieverAttached(reciever) { + isRecieverAttached(reciever: InputReceiver) { return this.recieverStack.indexOf(reciever) >= 0; } - /** - * @param {InputReceiver} reciever - */ - isRecieverOnTop(reciever) { + isRecieverOnTop(reciever: InputReceiver) { return ( this.isRecieverAttached(reciever) && this.recieverStack[this.recieverStack.length - 1] === reciever ); } - /** - * @param {InputReceiver} reciever - */ - makeSureAttachedAndOnTop(reciever) { + makeSureAttachedAndOnTop(reciever: InputReceiver) { this.makeSureDetached(reciever); this.pushReciever(reciever); } - /** - * @param {InputReceiver} reciever - */ - makeSureDetached(reciever) { + makeSureDetached(reciever: InputReceiver) { if (this.isRecieverAttached(reciever)) { arrayDeleteValue(this.recieverStack, reciever); } } - /** - * - * @param {InputReceiver} reciever - */ - destroyReceiver(reciever) { + destroyReceiver(reciever: InputReceiver) { this.makeSureDetached(reciever); reciever.cleanup(); } @@ -153,7 +118,10 @@ export class InputDistributor { document.addEventListener("paste", this.handlePaste.bind(this)); } - forwardToReceiver(eventId, payload = null) { + forwardToReceiver( + eventId: T, + payload: Parameters[0] = null + ) { // Check filters for (let i = 0; i < this.filters.length; ++i) { if (!this.filters[i](eventId)) { @@ -168,13 +136,11 @@ export class InputDistributor { } const signal = reciever[eventId]; assert(signal instanceof Signal, "Not a valid event id"); - return signal.dispatch(payload); + // probably not possible to type properly, since the types of `signal` and `payload` are correlated + return signal.dispatch(payload as never); } - /** - * @param {Event} event - */ - handleBackButton(event) { + handleBackButton(event: Event) { event.preventDefault(); event.stopPropagation(); this.forwardToReceiver("backButton"); @@ -184,21 +150,15 @@ export class InputDistributor { * Handles when the page got blurred */ handleBlur() { - this.forwardToReceiver("pageBlur", {}); + this.forwardToReceiver("pageBlur"); this.keysDown.clear(); } - /** - * - */ - handlePaste(ev) { + handlePaste(ev: ClipboardEvent) { this.forwardToReceiver("paste", ev); } - /** - * @param {KeyboardEvent | MouseEvent} event - */ - handleKeyMouseDown(event) { + handleKeyMouseDown(event: KeyboardEvent | MouseEvent) { const keyCode = event instanceof MouseEvent ? event.button + 1 : event.keyCode; if ( keyCode === 4 || // MB4 @@ -236,10 +196,7 @@ export class InputDistributor { } } - /** - * @param {KeyboardEvent | MouseEvent} event - */ - handleKeyMouseUp(event) { + handleKeyMouseUp(event: KeyboardEvent | MouseEvent) { const keyCode = event instanceof MouseEvent ? event.button + 1 : event.keyCode; this.keysDown.delete(keyCode); diff --git a/src/js/core/input_receiver.js b/src/js/core/input_receiver.js deleted file mode 100644 index 164ab84b..00000000 --- a/src/js/core/input_receiver.js +++ /dev/null @@ -1,27 +0,0 @@ -import { Signal } from "./signal"; - -export class InputReceiver { - constructor(context = "unknown") { - this.context = context; - - this.backButton = new Signal(); - - this.keydown = new Signal(); - this.keyup = new Signal(); - this.pageBlur = new Signal(); - - // Dispatched on destroy - this.destroyed = new Signal(); - - this.paste = new Signal(); - } - - cleanup() { - this.backButton.removeAll(); - this.keydown.removeAll(); - this.keyup.removeAll(); - this.paste.removeAll(); - - this.destroyed.dispatch(); - } -} diff --git a/src/js/core/input_receiver.ts b/src/js/core/input_receiver.ts new file mode 100644 index 00000000..da9a4934 --- /dev/null +++ b/src/js/core/input_receiver.ts @@ -0,0 +1,47 @@ +import { Signal } from "./signal"; + +export type KeydownEvent = { + keyCode: number; + shift: boolean; + alt: boolean; + ctrl: boolean; + initial: boolean; + event: KeyboardEvent | MouseEvent; +}; +export type KeyupEvent = { + keyCode: number; + shift: boolean; + alt: boolean; +}; + +export class InputReceiver { + public backButton = new Signal(); + + public keydown = new Signal<[KeydownEvent]>(); + public keyup = new Signal<[KeyupEvent]>(); + public pageBlur = new Signal(); + + // Dispatched on destroy + public destroyed = new Signal(); + + public paste = new Signal<[ClipboardEvent]>(); + + constructor(public context: string = "unknown") {} + + cleanup() { + this.backButton.removeAll(); + this.keydown.removeAll(); + this.keyup.removeAll(); + this.paste.removeAll(); + + this.destroyed.dispatch(); + } +} + +export type ReceiverId = keyof { + [K in keyof InputReceiver as InputReceiver[K] extends Signal + ? K extends "destroyed" + ? never + : K + : never]: unknown; +}; diff --git a/src/js/core/modal_dialog_elements.js b/src/js/core/modal_dialog_elements.ts similarity index 77% rename from src/js/core/modal_dialog_elements.js rename to src/js/core/modal_dialog_elements.ts index e90f322d..553156eb 100644 --- a/src/js/core/modal_dialog_elements.js +++ b/src/js/core/modal_dialog_elements.ts @@ -1,467 +1,502 @@ -/* typehints:start */ -import { Application } from "../application"; -/* typehints:end */ - -import { Signal, STOP_PROPAGATION } from "./signal"; -import { arrayDeleteValue, waitNextFrame } from "./utils"; -import { ClickDetector } from "./click_detector"; -import { SOUNDS } from "../platform/sound"; -import { InputReceiver } from "./input_receiver"; -import { FormElement } from "./modal_dialog_forms"; -import { globalConfig } from "./config"; -import { getStringForKeyCode } from "../game/key_action_mapper"; -import { createLogger } from "./logging"; -import { T } from "../translations"; - -/* - * *************************************************** - * - * LEGACY CODE WARNING - * - * This is old code from yorg3.io and needs to be refactored - * @TODO - * - * *************************************************** - */ - -const kbEnter = 13; -const kbCancel = 27; - -const logger = createLogger("dialogs"); - -/** - * Basic text based dialog - */ -export class Dialog { - /** - * - * Constructs a new dialog with the given options - * @param {object} param0 - * @param {Application} param0.app - * @param {string} param0.title Title of the dialog - * @param {string} param0.contentHTML Inner dialog html - * @param {Array} param0.buttons - * Button list, each button contains of up to 3 parts separated by ':'. - * Part 0: The id, one of the one defined in dialog_buttons.yaml - * Part 1: The style, either good, bad or misc - * Part 2 (optional): Additional parameters separated by '/', available are: - * timeout: This button is only available after some waiting time - * kb_enter: This button is triggered by the enter key - * kb_escape This button is triggered by the escape key - * @param {string=} param0.type The dialog type, either "info" or "warn" - * @param {boolean=} param0.closeButton Whether this dialog has a close button - */ - constructor({ app, title, contentHTML, buttons, type = "info", closeButton = false }) { - this.app = app; - this.title = title; - this.contentHTML = contentHTML; - this.type = type; - this.buttonIds = buttons; - this.closeButton = closeButton; - - this.closeRequested = new Signal(); - this.buttonSignals = {}; - - for (let i = 0; i < buttons.length; ++i) { - if (G_IS_DEV && globalConfig.debug.disableTimedButtons) { - this.buttonIds[i] = this.buttonIds[i].replace(":timeout", ""); - } - - const buttonId = this.buttonIds[i].split(":")[0]; - this.buttonSignals[buttonId] = new Signal(); - } - - this.valueChosen = new Signal(); - - this.timeouts = []; - this.clickDetectors = []; - - this.inputReciever = new InputReceiver("dialog-" + this.title); - - this.inputReciever.keydown.add(this.handleKeydown, this); - - this.enterHandler = null; - this.escapeHandler = null; - } - - /** - * Internal keydown handler - * @param {object} param0 - * @param {number} param0.keyCode - * @param {boolean} param0.shift - * @param {boolean} param0.alt - * @param {boolean} param0.ctrl - */ - handleKeydown({ keyCode, shift, alt, ctrl }) { - if (keyCode === kbEnter && this.enterHandler) { - this.internalButtonHandler(this.enterHandler); - return STOP_PROPAGATION; - } - - if (keyCode === kbCancel && this.escapeHandler) { - this.internalButtonHandler(this.escapeHandler); - return STOP_PROPAGATION; - } - } - - internalButtonHandler(id, ...payload) { - this.app.inputMgr.popReciever(this.inputReciever); - - if (id !== "close-button") { - this.buttonSignals[id].dispatch(...payload); - } - this.closeRequested.dispatch(); - } - - createElement() { - const elem = document.createElement("div"); - elem.classList.add("ingameDialog"); - - this.dialogElem = document.createElement("div"); - this.dialogElem.classList.add("dialogInner"); - - if (this.type) { - this.dialogElem.classList.add(this.type); - } - elem.appendChild(this.dialogElem); - - const title = document.createElement("h1"); - title.innerText = this.title; - title.classList.add("title"); - this.dialogElem.appendChild(title); - - if (this.closeButton) { - this.dialogElem.classList.add("hasCloseButton"); - - const closeBtn = document.createElement("button"); - closeBtn.classList.add("closeButton"); - - this.trackClicks(closeBtn, () => this.internalButtonHandler("close-button"), { - applyCssClass: "pressedSmallElement", - }); - - title.appendChild(closeBtn); - this.inputReciever.backButton.add(() => this.internalButtonHandler("close-button")); - } - - const content = document.createElement("div"); - content.classList.add("content"); - content.innerHTML = this.contentHTML; - this.dialogElem.appendChild(content); - - if (this.buttonIds.length > 0) { - const buttons = document.createElement("div"); - buttons.classList.add("buttons"); - - // Create buttons - for (let i = 0; i < this.buttonIds.length; ++i) { - const [buttonId, buttonStyle, rawParams] = this.buttonIds[i].split(":"); - - const button = document.createElement("button"); - button.classList.add("button"); - button.classList.add("styledButton"); - button.classList.add(buttonStyle); - button.innerText = T.dialogs.buttons[buttonId]; - - const params = (rawParams || "").split("/"); - const useTimeout = params.indexOf("timeout") >= 0; - - const isEnter = params.indexOf("enter") >= 0; - const isEscape = params.indexOf("escape") >= 0; - - if (isEscape && this.closeButton) { - logger.warn("Showing dialog with close button, and additional cancel button"); - } - - if (useTimeout) { - button.classList.add("timedButton"); - const timeout = setTimeout(() => { - button.classList.remove("timedButton"); - arrayDeleteValue(this.timeouts, timeout); - }, 1000); - this.timeouts.push(timeout); - } - if (isEnter || isEscape) { - // if (this.app.settings.getShowKeyboardShortcuts()) { - // Show keybinding - const spacer = document.createElement("code"); - spacer.classList.add("keybinding"); - spacer.innerHTML = getStringForKeyCode(isEnter ? kbEnter : kbCancel); - button.appendChild(spacer); - // } - - if (isEnter) { - this.enterHandler = buttonId; - } - if (isEscape) { - this.escapeHandler = buttonId; - } - } - - this.trackClicks(button, () => this.internalButtonHandler(buttonId)); - buttons.appendChild(button); - } - - this.dialogElem.appendChild(buttons); - } else { - this.dialogElem.classList.add("buttonless"); - } - - this.element = elem; - this.app.inputMgr.pushReciever(this.inputReciever); - - return this.element; - } - - setIndex(index) { - this.element.style.zIndex = index; - } - - destroy() { - if (!this.element) { - assert(false, "Tried to destroy dialog twice"); - return; - } - // We need to do this here, because if the backbutton event gets - // dispatched to the modal dialogs, it will not call the internalButtonHandler, - // and thus our receiver stays attached the whole time - this.app.inputMgr.destroyReceiver(this.inputReciever); - - for (let i = 0; i < this.clickDetectors.length; ++i) { - this.clickDetectors[i].cleanup(); - } - this.clickDetectors = []; - - this.element.remove(); - this.element = null; - - for (let i = 0; i < this.timeouts.length; ++i) { - clearTimeout(this.timeouts[i]); - } - this.timeouts = []; - } - - hide() { - this.element.classList.remove("visible"); - } - - show() { - this.element.classList.add("visible"); - } - - /** - * Helper method to track clicks on an element - * @param {Element} elem - * @param {function():void} handler - * @param {import("./click_detector").ClickDetectorConstructorArgs=} args - * @returns {ClickDetector} - */ - trackClicks(elem, handler, args = {}) { - const detector = new ClickDetector(elem, args); - detector.click.add(handler, this); - this.clickDetectors.push(detector); - return detector; - } -} - -/** - * Dialog which simply shows a loading spinner - */ -export class DialogLoading extends Dialog { - constructor(app, text = "") { - super({ - app, - title: "", - contentHTML: "", - buttons: [], - type: "loading", - }); - - // Loading dialog can not get closed with back button - this.inputReciever.backButton.removeAll(); - this.inputReciever.context = "dialog-loading"; - - this.text = text; - } - - createElement() { - const elem = document.createElement("div"); - elem.classList.add("ingameDialog"); - elem.classList.add("loadingDialog"); - this.element = elem; - - if (this.text) { - const text = document.createElement("div"); - text.classList.add("text"); - text.innerText = this.text; - elem.appendChild(text); - } - - const loader = document.createElement("div"); - loader.classList.add("prefab_LoadingTextWithAnim"); - loader.classList.add("loadingIndicator"); - elem.appendChild(loader); - - this.app.inputMgr.pushReciever(this.inputReciever); - - return elem; - } -} - -export class DialogOptionChooser extends Dialog { - constructor({ app, title, options }) { - let html = "
"; - - options.options.forEach(({ value, text, desc = null, iconPrefix = null }) => { - const descHtml = desc ? `${desc}` : ""; - let iconHtml = iconPrefix ? `` : ""; - html += ` -
- ${iconHtml} - ${text} - ${descHtml} -
- `; - }); - - html += "
"; - super({ - app, - title, - contentHTML: html, - buttons: [], - type: "info", - closeButton: true, - }); - - this.options = options; - this.initialOption = options.active; - - this.buttonSignals.optionSelected = new Signal(); - } - - createElement() { - const div = super.createElement(); - this.dialogElem.classList.add("optionChooserDialog"); - - div.querySelectorAll("[data-optionvalue]").forEach(handle => { - const value = handle.getAttribute("data-optionvalue"); - if (!handle) { - logger.error("Failed to bind option value in dialog:", value); - return; - } - // Need click detector here to forward elements, otherwise scrolling does not work - const detector = new ClickDetector(handle, { - consumeEvents: false, - preventDefault: false, - clickSound: null, - applyCssClass: "pressedOption", - targetOnly: true, - }); - this.clickDetectors.push(detector); - - if (value !== this.initialOption) { - detector.click.add(() => { - const selected = div.querySelector(".option.active"); - if (selected) { - selected.classList.remove("active"); - } else { - logger.warn("No selected option"); - } - handle.classList.add("active"); - this.app.sound.playUiSound(SOUNDS.uiClick); - this.internalButtonHandler("optionSelected", value); - }); - } - }); - return div; - } -} - -export class DialogWithForm extends Dialog { - /** - * - * @param {object} param0 - * @param {Application} param0.app - * @param {string} param0.title - * @param {string} param0.desc - * @param {array=} param0.buttons - * @param {string=} param0.confirmButtonId - * @param {string=} param0.extraButton - * @param {boolean=} param0.closeButton - * @param {Array} param0.formElements - */ - constructor({ - app, - title, - desc, - formElements, - buttons = ["cancel", "ok:good"], - confirmButtonId = "ok", - closeButton = true, - }) { - let html = ""; - html += desc + "
"; - for (let i = 0; i < formElements.length; ++i) { - html += formElements[i].getHtml(); - } - - super({ - app, - title: title, - contentHTML: html, - buttons: buttons, - type: "info", - closeButton, - }); - this.confirmButtonId = confirmButtonId; - this.formElements = formElements; - - this.enterHandler = confirmButtonId; - } - - internalButtonHandler(id, ...payload) { - if (id === this.confirmButtonId) { - if (this.hasAnyInvalid()) { - this.dialogElem.classList.remove("errorShake"); - waitNextFrame().then(() => { - if (this.dialogElem) { - this.dialogElem.classList.add("errorShake"); - } - }); - this.app.sound.playUiSound(SOUNDS.uiError); - return; - } - } - - super.internalButtonHandler(id, payload); - } - - hasAnyInvalid() { - for (let i = 0; i < this.formElements.length; ++i) { - if (!this.formElements[i].isValid()) { - return true; - } - } - return false; - } - - createElement() { - const div = super.createElement(); - - for (let i = 0; i < this.formElements.length; ++i) { - const elem = this.formElements[i]; - elem.bindEvents(div, this.clickDetectors); - // elem.valueChosen.add(this.closeRequested.dispatch, this.closeRequested); - elem.valueChosen.add(this.valueChosen.dispatch, this.valueChosen); - } - - waitNextFrame().then(() => { - this.formElements[this.formElements.length - 1].focus(); - }); - - return div; - } -} +import type { Application } from "../application"; + +import { Signal, STOP_PROPAGATION } from "./signal"; +import { arrayDeleteValue, waitNextFrame } from "./utils"; +import { ClickDetector, ClickDetectorConstructorArgs } from "./click_detector"; +import { SOUNDS } from "../platform/sound"; +import { InputReceiver, KeydownEvent } from "./input_receiver"; +import { FormElement } from "./modal_dialog_forms"; +import { globalConfig } from "./config"; +import { getStringForKeyCode } from "../game/key_action_mapper"; +import { createLogger } from "./logging"; +import { T } from "../translations"; + +/* + * *************************************************** + * + * LEGACY CODE WARNING + * + * This is old code from yorg3.io and needs to be refactored + * @TODO + * + * *************************************************** + */ + +const kbEnter = 13; +const kbCancel = 27; + +const logger = createLogger("dialogs"); + +export type DialogButtonStr = `${T}:${string}` | T; +export type DialogButtonType = "info" | "loading" | "warning"; + +/** + * Basic text based dialog + */ +export class Dialog { + public title: string; + public app: Application; + public contentHTML: string; + public type: string; + public buttonIds: string[]; + public closeButton: boolean; + public dialogElem: HTMLDivElement; + public element: HTMLDivElement; + + public closeRequested = new Signal(); + public buttonSignals = {} as Record>; + + public valueChosen = new Signal<[unknown]>(); + + public timeouts: number[] = []; + public clickDetectors: ClickDetector[] = []; + + public inputReciever: InputReceiver; + public enterHandler: T = null; + public escapeHandler: T = null; + + /** + * + * Constructs a new dialog with the given options + * @param param0 + * @param param0.title Title of the dialog + * @param param0.contentHTML Inner dialog html + * @param param0.buttons + * Button list, each button contains of up to 3 parts separated by ':'. + * Part 0: The id, one of the one defined in dialog_buttons.yaml + * Part 1: The style, either good, bad or misc + * Part 2 (optional): Additional parameters separated by '/', available are: + * timeout: This button is only available after some waiting time + * kb_enter: This button is triggered by the enter key + * kb_escape This button is triggered by the escape key + * @param param0.type The dialog type, either "info", "warning", or "loading" + * @param param0.closeButton Whether this dialog has a close button + */ + constructor({ + app, + title, + contentHTML, + buttons, + type = "info", + closeButton = false, + }: { + app: Application; + title: string; + contentHTML: string; + buttons?: DialogButtonStr[]; + type?: DialogButtonType; + closeButton?: boolean; + }) { + this.app = app; + this.title = title; + this.contentHTML = contentHTML; + this.type = type; + this.buttonIds = buttons; + this.closeButton = closeButton; + + for (let i = 0; i < buttons.length; ++i) { + if (G_IS_DEV && globalConfig.debug.disableTimedButtons) { + this.buttonIds[i] = this.buttonIds[i].replace(":timeout", ""); + } + + const buttonId = this.buttonIds[i].split(":")[0]; + this.buttonSignals[buttonId] = new Signal(); + } + + this.inputReciever = new InputReceiver("dialog-" + this.title); + + this.inputReciever.keydown.add(this.handleKeydown, this); + } + + /** + * Internal keydown handler + */ + handleKeydown({ keyCode, shift, alt, ctrl }: KeydownEvent): void | STOP_PROPAGATION { + if (keyCode === kbEnter && this.enterHandler) { + this.internalButtonHandler(this.enterHandler); + return STOP_PROPAGATION; + } + + if (keyCode === kbCancel && this.escapeHandler) { + this.internalButtonHandler(this.escapeHandler); + return STOP_PROPAGATION; + } + } + + internalButtonHandler(id: T | "close-button", ...payload: U | []) { + this.app.inputMgr.popReciever(this.inputReciever); + + if (id !== "close-button") { + this.buttonSignals[id].dispatch(...payload); + } + this.closeRequested.dispatch(); + } + + createElement() { + const elem = document.createElement("div"); + elem.classList.add("ingameDialog"); + + this.dialogElem = document.createElement("div"); + this.dialogElem.classList.add("dialogInner"); + + if (this.type) { + this.dialogElem.classList.add(this.type); // @TODO: `this.type` seems unused + } + elem.appendChild(this.dialogElem); + + const title = document.createElement("h1"); + title.innerText = this.title; + title.classList.add("title"); + this.dialogElem.appendChild(title); + + if (this.closeButton) { + this.dialogElem.classList.add("hasCloseButton"); + + const closeBtn = document.createElement("button"); + closeBtn.classList.add("closeButton"); + + this.trackClicks(closeBtn, () => this.internalButtonHandler("close-button"), { + applyCssClass: "pressedSmallElement", + }); + + title.appendChild(closeBtn); + this.inputReciever.backButton.add(() => this.internalButtonHandler("close-button")); + } + + const content = document.createElement("div"); + content.classList.add("content"); + content.innerHTML = this.contentHTML; + this.dialogElem.appendChild(content); + + if (this.buttonIds.length > 0) { + const buttons = document.createElement("div"); + buttons.classList.add("buttons"); + + // Create buttons + for (let i = 0; i < this.buttonIds.length; ++i) { + const [buttonId, buttonStyle, rawParams] = this.buttonIds[i].split(":") as [ + T, + string, + string? + ]; // @TODO: some button strings omit `buttonStyle` + + const button = document.createElement("button"); + button.classList.add("button"); + button.classList.add("styledButton"); + button.classList.add(buttonStyle); + button.innerText = T.dialogs.buttons[buttonId as string]; + + const params = (rawParams || "").split("/"); + const useTimeout = params.indexOf("timeout") >= 0; + + const isEnter = params.indexOf("enter") >= 0; + const isEscape = params.indexOf("escape") >= 0; + + if (isEscape && this.closeButton) { + logger.warn("Showing dialog with close button, and additional cancel button"); + } + + if (useTimeout) { + button.classList.add("timedButton"); + const timeout = setTimeout(() => { + button.classList.remove("timedButton"); + arrayDeleteValue(this.timeouts, timeout); + }, 1000) as unknown as number; // @TODO: @types/node should not be affecting this + this.timeouts.push(timeout); + } + if (isEnter || isEscape) { + // if (this.app.settings.getShowKeyboardShortcuts()) { + // Show keybinding + const spacer = document.createElement("code"); + spacer.classList.add("keybinding"); + spacer.innerHTML = getStringForKeyCode(isEnter ? kbEnter : kbCancel); + button.appendChild(spacer); + // } + + if (isEnter) { + this.enterHandler = buttonId; + } + if (isEscape) { + this.escapeHandler = buttonId; + } + } + + this.trackClicks(button, () => this.internalButtonHandler(buttonId)); + buttons.appendChild(button); + } + + this.dialogElem.appendChild(buttons); + } else { + this.dialogElem.classList.add("buttonless"); + } + + this.element = elem; + this.app.inputMgr.pushReciever(this.inputReciever); + + return this.element; + } + + setIndex(index: string) { + this.element.style.zIndex = index; + } + + destroy() { + if (!this.element) { + assert(false, "Tried to destroy dialog twice"); + return; + } + // We need to do this here, because if the backbutton event gets + // dispatched to the modal dialogs, it will not call the internalButtonHandler, + // and thus our receiver stays attached the whole time + this.app.inputMgr.destroyReceiver(this.inputReciever); + + for (let i = 0; i < this.clickDetectors.length; ++i) { + this.clickDetectors[i].cleanup(); + } + this.clickDetectors = []; + + this.element.remove(); + this.element = null; + + for (let i = 0; i < this.timeouts.length; ++i) { + clearTimeout(this.timeouts[i]); + } + this.timeouts = []; + } + + hide() { + this.element.classList.remove("visible"); + } + + show() { + this.element.classList.add("visible"); + } + + /** + * Helper method to track clicks on an element + */ + trackClicks(elem: Element, handler: () => void, args: ClickDetectorConstructorArgs = {}) { + const detector = new ClickDetector(elem, args); + detector.click.add(handler, this); + this.clickDetectors.push(detector); + return detector; + } +} + +/** + * Dialog which simply shows a loading spinner + */ +export class DialogLoading extends Dialog { + constructor(app: Application, public text = "") { + super({ + app, + title: "", + contentHTML: "", + buttons: [], + type: "loading", + }); + + // Loading dialog can not get closed with back button + this.inputReciever.backButton.removeAll(); + this.inputReciever.context = "dialog-loading"; + } + + createElement() { + const elem = document.createElement("div"); + elem.classList.add("ingameDialog"); + elem.classList.add("loadingDialog"); + this.element = elem; + + if (this.text) { + const text = document.createElement("div"); + text.classList.add("text"); + text.innerText = this.text; + elem.appendChild(text); + } + + const loader = document.createElement("div"); + loader.classList.add("prefab_LoadingTextWithAnim"); + loader.classList.add("loadingIndicator"); + elem.appendChild(loader); + + this.app.inputMgr.pushReciever(this.inputReciever); + + return elem; + } +} + +type DialogOptionChooserOption = { value: string; text: string; desc?: string; iconPrefix?: string }; +export class DialogOptionChooser extends Dialog<"optionSelected", [string]> { + public options: { + options: DialogOptionChooserOption[]; + active: string; + }; + public initialOption: string; + + constructor({ + app, + title, + options, + }: { + app: Application; + title: string; + options: { + options: DialogOptionChooserOption[]; + active: string; + }; + }) { + let html = "
"; + + options.options.forEach(({ value, text, desc = null, iconPrefix = null }) => { + const descHtml = desc ? `${desc}` : ""; + const iconHtml = iconPrefix ? `` : ""; + html += ` +
+ ${iconHtml} + ${text} + ${descHtml} +
+ `; + }); + + html += "
"; + super({ + app, + title, + contentHTML: html, + buttons: [], + type: "info", + closeButton: true, + }); + + this.options = options; + this.initialOption = options.active; + + this.buttonSignals.optionSelected = new Signal(); + } + + createElement() { + const div = super.createElement(); + this.dialogElem.classList.add("optionChooserDialog"); + + div.querySelectorAll("[data-optionvalue]").forEach(handle => { + const value = handle.getAttribute("data-optionvalue"); + if (!handle) { + logger.error("Failed to bind option value in dialog:", value); + return; + } + // Need click detector here to forward elements, otherwise scrolling does not work + const detector = new ClickDetector(handle, { + consumeEvents: false, + preventDefault: false, + clickSound: null, + applyCssClass: "pressedOption", + targetOnly: true, + }); + this.clickDetectors.push(detector); + + if (value !== this.initialOption) { + detector.click.add(() => { + const selected = div.querySelector(".option.active"); + if (selected) { + selected.classList.remove("active"); + } else { + logger.warn("No selected option"); + } + handle.classList.add("active"); + this.app.sound.playUiSound(SOUNDS.uiClick); + this.internalButtonHandler("optionSelected", value); + }); + } + }); + return div; + } +} + +export class DialogWithForm extends Dialog { + public confirmButtonId: string; + // `FormElement` is invariant so `unknown` and `never` don't work + public formElements: FormElement[]; + + constructor({ + app, + title, + desc, + formElements, + buttons = ["cancel", "ok:good"] as any, + confirmButtonId = "ok" as any, + closeButton = true, + }: { + app: Application; + title: string; + desc: string; + formElements: FormElement[]; + buttons?: DialogButtonStr[]; + confirmButtonId?: T; + closeButton?: boolean; + }) { + let html = ""; + html += desc + "
"; + for (let i = 0; i < formElements.length; ++i) { + html += formElements[i].getHtml(); + } + + super({ + app, + title: title, + contentHTML: html, + buttons: buttons, + type: "info", + closeButton, + }); + this.confirmButtonId = confirmButtonId; + this.formElements = formElements; + + this.enterHandler = confirmButtonId; + } + + internalButtonHandler(id: T | "close-button", ...payload: []) { + if (id === this.confirmButtonId) { + if (this.hasAnyInvalid()) { + this.dialogElem.classList.remove("errorShake"); + waitNextFrame().then(() => { + if (this.dialogElem) { + this.dialogElem.classList.add("errorShake"); + } + }); + this.app.sound.playUiSound(SOUNDS.uiError); + return; + } + } + + super.internalButtonHandler(id, ...payload); + } + + hasAnyInvalid() { + for (let i = 0; i < this.formElements.length; ++i) { + if (!this.formElements[i].isValid()) { + return true; + } + } + return false; + } + + createElement() { + const div = super.createElement(); + + for (let i = 0; i < this.formElements.length; ++i) { + const elem = this.formElements[i]; + elem.bindEvents(div, this.clickDetectors); + // elem.valueChosen.add(this.closeRequested.dispatch, this.closeRequested); + elem.valueChosen.add(this.valueChosen.dispatch, this.valueChosen); + } + + waitNextFrame().then(() => { + this.formElements[this.formElements.length - 1].focus(); + }); + + return div; + } +} diff --git a/src/js/core/modal_dialog_forms.js b/src/js/core/modal_dialog_forms.ts similarity index 69% rename from src/js/core/modal_dialog_forms.js rename to src/js/core/modal_dialog_forms.ts index 355fad09..4c77d305 100644 --- a/src/js/core/modal_dialog_forms.js +++ b/src/js/core/modal_dialog_forms.ts @@ -1,238 +1,238 @@ -import { BaseItem } from "../game/base_item"; -import { ClickDetector } from "./click_detector"; -import { Signal } from "./signal"; - -/* - * *************************************************** - * - * LEGACY CODE WARNING - * - * This is old code from yorg3.io and needs to be refactored - * @TODO - * - * *************************************************** - */ - -export class FormElement { - constructor(id, label) { - this.id = id; - this.label = label; - - this.valueChosen = new Signal(); - } - - getHtml() { - abstract; - return ""; - } - - getFormElement(parent) { - return parent.querySelector("[data-formId='" + this.id + "']"); - } - - bindEvents(parent, clickTrackers) { - abstract; - } - - focus() {} - - isValid() { - return true; - } - - /** @returns {any} */ - getValue() { - abstract; - } -} - -export class FormElementInput extends FormElement { - constructor({ id, label = null, placeholder, defaultValue = "", inputType = "text", validator = null }) { - super(id, label); - this.placeholder = placeholder; - this.defaultValue = defaultValue; - this.inputType = inputType; - this.validator = validator; - - this.element = null; - } - - getHtml() { - let classes = []; - let inputType = "text"; - let maxlength = 256; - switch (this.inputType) { - case "text": { - classes.push("input-text"); - break; - } - - case "email": { - classes.push("input-email"); - inputType = "email"; - break; - } - - case "token": { - classes.push("input-token"); - inputType = "text"; - maxlength = 4; - break; - } - } - - return ` -
- ${this.label ? `` : ""} - -
- `; - } - - bindEvents(parent, clickTrackers) { - this.element = this.getFormElement(parent); - this.element.addEventListener("input", event => this.updateErrorState()); - this.updateErrorState(); - } - - updateErrorState() { - this.element.classList.toggle("errored", !this.isValid()); - } - - isValid() { - return !this.validator || this.validator(this.element.value); - } - - getValue() { - return this.element.value; - } - - setValue(value) { - this.element.value = value; - this.updateErrorState(); - } - - focus() { - this.element.focus(); - this.element.select(); - } -} - -export class FormElementCheckbox extends FormElement { - constructor({ id, label, defaultValue = true }) { - super(id, label); - this.defaultValue = defaultValue; - this.value = this.defaultValue; - - this.element = null; - } - - getHtml() { - return ` -
- ${this.label ? `` : ""} -
- -
-
- `; - } - - bindEvents(parent, clickTrackers) { - this.element = this.getFormElement(parent); - const detector = new ClickDetector(this.element, { - consumeEvents: false, - preventDefault: false, - }); - clickTrackers.push(detector); - detector.click.add(this.toggle, this); - } - - getValue() { - return this.value; - } - - toggle() { - this.value = !this.value; - this.element.classList.toggle("checked", this.value); - } - - focus(parent) {} -} - -export class FormElementItemChooser extends FormElement { - /** - * - * @param {object} param0 - * @param {string} param0.id - * @param {string=} param0.label - * @param {Array} param0.items - */ - constructor({ id, label, items = [] }) { - super(id, label); - this.items = items; - this.element = null; - - /** - * @type {BaseItem} - */ - this.chosenItem = null; - } - - getHtml() { - let classes = []; - - return ` -
- ${this.label ? `` : ""} -
-
- `; - } - - /** - * @param {HTMLElement} parent - * @param {Array} clickTrackers - */ - bindEvents(parent, clickTrackers) { - this.element = this.getFormElement(parent); - - for (let i = 0; i < this.items.length; ++i) { - const item = this.items[i]; - - const canvas = document.createElement("canvas"); - canvas.width = 128; - canvas.height = 128; - const context = canvas.getContext("2d"); - item.drawFullSizeOnCanvas(context, 128); - this.element.appendChild(canvas); - - const detector = new ClickDetector(canvas, {}); - clickTrackers.push(detector); - detector.click.add(() => { - this.chosenItem = item; - this.valueChosen.dispatch(item); - }); - } - } - - isValid() { - return true; - } - - getValue() { - return null; - } - - focus() {} -} +import { BaseItem } from "../game/base_item"; +import { ClickDetector } from "./click_detector"; +import { Signal } from "./signal"; + +/* + * *************************************************** + * + * LEGACY CODE WARNING + * + * This is old code from yorg3.io and needs to be refactored + * @TODO + * + * *************************************************** + */ + +export abstract class FormElement { + public valueChosen = new Signal<[T]>(); + + constructor(public id: string, public label: string) {} + + abstract getHtml(): string; + + getFormElement(parent: HTMLElement): HTMLElement { + return parent.querySelector("[data-formId='" + this.id + "']"); + } + + abstract bindEvents(parent: HTMLDivElement, clickTrackers: ClickDetector[]): void; + + focus() {} + + isValid() { + return true; + } + + abstract getValue(): T; +} + +export class FormElementInput extends FormElement { + public placeholder: string; + public defaultValue: string; + public inputType: "text" | "email" | "token"; + public validator: (value: string) => boolean; + + public element: HTMLInputElement = null; + + constructor({ + id, + label = null, + placeholder, + defaultValue = "", + inputType = "text", + validator = null, + }: { + id: string; + label?: string; + placeholder: string; + defaultValue?: string; + inputType?: "text" | "email" | "token"; + validator?: (value: string) => boolean; + }) { + super(id, label); + this.placeholder = placeholder; + this.defaultValue = defaultValue; + this.inputType = inputType; + this.validator = validator; + } + + getHtml() { + const classes = []; + let inputType = "text"; + let maxlength = 256; + // @TODO: `inputType` and these classes are unused + switch (this.inputType) { + case "text": { + classes.push("input-text"); + break; + } + + case "email": { + classes.push("input-email"); + inputType = "email"; + break; + } + + case "token": { + classes.push("input-token"); + inputType = "text"; + maxlength = 4; + break; + } + } + + return ` +
+ ${this.label ? `` : ""} + +
+ `; + } + + bindEvents(parent: HTMLDivElement, clickTrackers: ClickDetector[]) { + this.element = this.getFormElement(parent) as HTMLInputElement; + this.element.addEventListener("input", event => this.updateErrorState()); + this.updateErrorState(); + } + + updateErrorState() { + this.element.classList.toggle("errored", !this.isValid()); + } + + isValid() { + return !this.validator || this.validator(this.element.value); + } + + getValue() { + return this.element.value; + } + + setValue(value: string) { + this.element.value = value; + this.updateErrorState(); + } + + focus() { + this.element.focus(); + this.element.select(); + } +} + +export class FormElementCheckbox extends FormElement { + public defaultValue: boolean; + public value: boolean; + public element: HTMLDivElement; + + constructor({ id, label, defaultValue = true }) { + super(id, label); + this.defaultValue = defaultValue; + this.value = this.defaultValue; + + this.element = null; + } + + getHtml() { + return ` +
+ ${this.label ? `` : ""} +
+ +
+
+ `; + } + + bindEvents(parent: HTMLDivElement, clickTrackers: ClickDetector[]) { + this.element = this.getFormElement(parent) as HTMLDivElement; + const detector = new ClickDetector(this.element, { + consumeEvents: false, + preventDefault: false, + }); + clickTrackers.push(detector); + detector.click.add(this.toggle, this); + } + + getValue() { + return this.value; + } + + toggle() { + this.value = !this.value; + this.element.classList.toggle("checked", this.value); + } + + focus() {} +} + +export class FormElementItemChooser extends FormElement { + public items: BaseItem[]; + public element: HTMLDivElement = null; + public chosenItem: BaseItem = null; + + constructor({ id, label, items = [] }: { id: string; label: string; items: BaseItem[] }) { + super(id, label); + this.items = items; + } + + getHtml() { + const classes = []; + + return ` +
+ ${this.label ? `` : ""} +
+
+ `; + } + + bindEvents(parent: HTMLElement, clickTrackers: ClickDetector[]) { + this.element = this.getFormElement(parent) as HTMLDivElement; + + for (let i = 0; i < this.items.length; ++i) { + const item = this.items[i]; + + const canvas = document.createElement("canvas"); + canvas.width = 128; + canvas.height = 128; + const context = canvas.getContext("2d"); + item.drawFullSizeOnCanvas(context, 128); + this.element.appendChild(canvas); + + const detector = new ClickDetector(canvas, {}); + clickTrackers.push(detector); + detector.click.add(() => { + this.chosenItem = item; + this.valueChosen.dispatch(item); + }); + } + } + + isValid() { + return true; + } + + getValue() { + return null; + } + + focus() {} +} diff --git a/src/js/core/signal.js b/src/js/core/signal.ts similarity index 67% rename from src/js/core/signal.js rename to src/js/core/signal.ts index 2dbc9f93..52b27387 100644 --- a/src/js/core/signal.js +++ b/src/js/core/signal.ts @@ -1,17 +1,16 @@ -export const STOP_PROPAGATION = "stop_propagation"; +export const STOP_PROPAGATION = "stop_propagation" as const; +export type STOP_PROPAGATION = typeof STOP_PROPAGATION; -export class Signal { - constructor() { - this.receivers = []; - this.modifyCount = 0; - } +export type SignalReceiver = (...args: T) => STOP_PROPAGATION | void; + +export class Signal { + public receivers: { receiver: SignalReceiver; scope: object }[] = []; + public modifyCount: number = 0; /** * Adds a new signal listener - * @param {function} receiver - * @param {object} scope */ - add(receiver, scope = null) { + add(receiver: SignalReceiver, scope: object = null) { assert(receiver, "receiver is null"); this.receivers.push({ receiver, scope }); ++this.modifyCount; @@ -19,10 +18,8 @@ export class Signal { /** * Adds a new signal listener - * @param {function} receiver - * @param {object} scope */ - addToTop(receiver, scope = null) { + addToTop(receiver: SignalReceiver, scope: object = null) { assert(receiver, "receiver is null"); this.receivers.unshift({ receiver, scope }); ++this.modifyCount; @@ -30,15 +27,14 @@ export class Signal { /** * Dispatches the signal - * @param {...any} payload */ - dispatch() { + dispatch(...payload: T): void | STOP_PROPAGATION { const modifyState = this.modifyCount; const n = this.receivers.length; for (let i = 0; i < n; ++i) { const { receiver, scope } = this.receivers[i]; - if (receiver.apply(scope, arguments) === STOP_PROPAGATION) { + if (receiver.apply(scope, payload) === STOP_PROPAGATION) { return STOP_PROPAGATION; } @@ -51,9 +47,8 @@ export class Signal { /** * Removes a receiver - * @param {function} receiver */ - remove(receiver) { + remove(receiver: SignalReceiver) { let index = null; const n = this.receivers.length; for (let i = 0; i < n; ++i) { diff --git a/src/js/core/singleton_factory.js b/src/js/core/singleton_factory.ts similarity index 73% rename from src/js/core/singleton_factory.js rename to src/js/core/singleton_factory.ts index 7fa38bd3..141611cb 100644 --- a/src/js/core/singleton_factory.js +++ b/src/js/core/singleton_factory.ts @@ -3,20 +3,18 @@ import { createLogger } from "./logging"; const logger = createLogger("singleton_factory"); // simple factory pattern -export class SingletonFactory { - constructor(id) { - this.id = id; +export class SingletonFactory { + // Store array as well as dictionary, to speed up lookups + public entries: T[] = []; + public idToEntry: Record = {}; - // Store array as well as dictionary, to speed up lookups - this.entries = []; - this.idToEntry = {}; - } + constructor(public id: string) {} getId() { return this.id; } - register(classHandle) { + register(classHandle: Class) { // First, construct instance const instance = new classHandle(); @@ -34,19 +32,15 @@ export class SingletonFactory { /** * Checks if a given id is registered - * @param {string} id - * @returns {boolean} */ - hasId(id) { + hasId(id: string): boolean { return !!this.idToEntry[id]; } /** * Finds an instance by a given id - * @param {string} id - * @returns {object} */ - findById(id) { + findById(id: string): T { const entry = this.idToEntry[id]; if (!entry) { logger.error("Object with id", id, "is not registered!"); @@ -58,10 +52,8 @@ export class SingletonFactory { /** * Finds an instance by its constructor (The class handle) - * @param {object} classHandle - * @returns {object} */ - findByClass(classHandle) { + findByClass(classHandle: Class): T { for (let i = 0; i < this.entries.length; ++i) { if (this.entries[i] instanceof classHandle) { return this.entries[i]; @@ -73,25 +65,22 @@ export class SingletonFactory { /** * Returns all entries - * @returns {Array} */ - getEntries() { + getEntries(): T[] { return this.entries; } /** * Returns all registered ids - * @returns {Array} */ - getAllIds() { + getAllIds(): string[] { return Object.keys(this.idToEntry); } /** * Returns amount of stored entries - * @returns {number} */ - getNumEntries() { + getNumEntries(): number { return this.entries.length; } } diff --git a/src/js/core/tracked_state.js b/src/js/core/tracked_state.ts similarity index 69% rename from src/js/core/tracked_state.js rename to src/js/core/tracked_state.ts index 1a538bc6..f5da2ab4 100644 --- a/src/js/core/tracked_state.js +++ b/src/js/core/tracked_state.ts @@ -1,7 +1,10 @@ -export class TrackedState { - constructor(callbackMethod = null, callbackScope = null) { - this.lastSeenValue = null; +export type TrackedStateCallback = (value: T) => void; +export class TrackedState { + public lastSeenValue: T = null; + public callback: TrackedStateCallback; + + constructor(callbackMethod: TrackedStateCallback = null, callbackScope: unknown = null) { if (callbackMethod) { this.callback = callbackMethod; if (callbackScope) { @@ -10,7 +13,7 @@ export class TrackedState { } } - set(value, changeHandler = null, changeScope = null) { + set(value: T, changeHandler: TrackedStateCallback = null, changeScope: unknown = null) { if (value !== this.lastSeenValue) { // Copy value since the changeHandler call could actually modify our lastSeenValue const valueCopy = value; @@ -29,11 +32,11 @@ export class TrackedState { } } - setSilent(value) { + setSilent(value: T) { this.lastSeenValue = value; } - get() { + get(): T { return this.lastSeenValue; } } diff --git a/src/js/game/camera.js b/src/js/game/camera.js index fc08d73f..b0d1dfe7 100644 --- a/src/js/game/camera.js +++ b/src/js/game/camera.js @@ -11,6 +11,7 @@ import { GameRoot } from "./root"; const logger = createLogger("camera"); +// @TODO: unused signal export const USER_INTERACT_MOVE = "move"; export const USER_INTERACT_ZOOM = "zoom"; export const USER_INTERACT_TOUCHEND = "touchend"; @@ -60,6 +61,7 @@ export class Camera extends BasicSerializableObject { this.keyboardForce = new Vector(); // Signal which gets emitted once the user changed something + /** @type {Signal<[string]>} */ this.userInteraction = new Signal(); /** @type {Vector} */ @@ -84,10 +86,10 @@ export class Camera extends BasicSerializableObject { this.touchPostMoveVelocity = new Vector(0, 0); // Handlers - this.downPreHandler = /** @type {TypedSignal<[Vector, enumMouseButton]>} */ (new Signal()); - this.movePreHandler = /** @type {TypedSignal<[Vector]>} */ (new Signal()); - // this.pinchPreHandler = /** @type {TypedSignal<[Vector]>} */ (new Signal()); - this.upPostHandler = /** @type {TypedSignal<[Vector]>} */ (new Signal()); + this.downPreHandler = /** @type {Signal<[Vector, enumMouseButton]>} */ (new Signal()); + this.movePreHandler = /** @type {Signal<[Vector]>} */ (new Signal()); + // this.pinchPreHandler = /** @type {Signal<[Vector]>} */ (new Signal()); + this.upPostHandler = /** @type {Signal<[Vector]>} */ (new Signal()); this.internalInitEvents(); this.clampZoomLevel(); diff --git a/src/js/game/entity.js b/src/js/game/entity.js index 9acaf26b..fc9d702d 100644 --- a/src/js/game/entity.js +++ b/src/js/game/entity.js @@ -7,7 +7,6 @@ import { GameRoot } from "./root"; import { globalConfig } from "../core/config"; import { enumDirectionToVector, enumDirectionToAngle } from "../core/vector"; import { BasicSerializableObject, types } from "../savegame/serialization"; -import { EntityComponentStorage } from "./entity_components"; import { Loader } from "../core/loader"; import { drawRotatedSprite } from "../core/draw_utils"; import { gComponentRegistry } from "../core/global_registries"; @@ -27,8 +26,9 @@ export class Entity extends BasicSerializableObject { /** * The components of the entity + * @type {import("./entity_components").EntityComponentStorage} */ - this.components = new EntityComponentStorage(); + this.components = {}; /** * Whether this entity was registered on the @see EntityManager so far @@ -99,7 +99,7 @@ export class Entity extends BasicSerializableObject { }); for (const key in this.components) { - /** @type {Component} */ (this.components[key]).copyAdditionalStateTo(clone.components[key]); + this.components[key].copyAdditionalStateTo(clone.components[key]); } return clone; diff --git a/src/js/game/entity_components.d.ts b/src/js/game/entity_components.d.ts new file mode 100644 index 00000000..e057dcf4 --- /dev/null +++ b/src/js/game/entity_components.d.ts @@ -0,0 +1,51 @@ +import type { BeltComponent } from "./components/belt"; +import type { BeltUnderlaysComponent } from "./components/belt_underlays"; +import type { HubComponent } from "./components/hub"; +import type { ItemAcceptorComponent } from "./components/item_acceptor"; +import type { ItemEjectorComponent } from "./components/item_ejector"; +import type { ItemProcessorComponent } from "./components/item_processor"; +import type { MinerComponent } from "./components/miner"; +import type { StaticMapEntityComponent } from "./components/static_map_entity"; +import type { StorageComponent } from "./components/storage"; +import type { UndergroundBeltComponent } from "./components/underground_belt"; +import type { WiredPinsComponent } from "./components/wired_pins"; +import type { WireComponent } from "./components/wire"; +import type { ConstantSignalComponent } from "./components/constant_signal"; +import type { LogicGateComponent } from "./components/logic_gate"; +import type { LeverComponent } from "./components/lever"; +import type { WireTunnelComponent } from "./components/wire_tunnel"; +import type { DisplayComponent } from "./components/display"; +import type { BeltReaderComponent } from "./components/belt_reader"; +import type { FilterComponent } from "./components/filter"; +import type { ItemProducerComponent } from "./components/item_producer"; +import type { GoalAcceptorComponent } from "./components/goal_acceptor"; +import type { Component } from "./component"; + +/** + * Typedefs for all entity components. + */ +export interface EntityComponentStorage { + StaticMapEntity?: StaticMapEntityComponent; + Belt?: BeltComponent; + ItemEjector?: ItemEjectorComponent; + ItemAcceptor?: ItemAcceptorComponent; + Miner?: MinerComponent; + ItemProcessor?: ItemProcessorComponent; + UndergroundBelt?: UndergroundBeltComponent; + Hub?: HubComponent; + Storage?: StorageComponent; + WiredPins?: WiredPinsComponent; + BeltUnderlays?: BeltUnderlaysComponent; + Wire?: WireComponent; + ConstantSignal?: ConstantSignalComponent; + LogicGate?: LogicGateComponent; + Lever?: LeverComponent; + WireTunnel?: WireTunnelComponent; + Display?: DisplayComponent; + BeltReader?: BeltReaderComponent; + Filter?: FilterComponent; + ItemProducer?: ItemProducerComponent; + GoalAcceptor?: GoalAcceptorComponent; + + [k: string]: Component; +} diff --git a/src/js/game/entity_components.js b/src/js/game/entity_components.js deleted file mode 100644 index 163be9f9..00000000 --- a/src/js/game/entity_components.js +++ /dev/null @@ -1,98 +0,0 @@ -/* typehints:start */ -import { BeltComponent } from "./components/belt"; -import { BeltUnderlaysComponent } from "./components/belt_underlays"; -import { HubComponent } from "./components/hub"; -import { ItemAcceptorComponent } from "./components/item_acceptor"; -import { ItemEjectorComponent } from "./components/item_ejector"; -import { ItemProcessorComponent } from "./components/item_processor"; -import { MinerComponent } from "./components/miner"; -import { StaticMapEntityComponent } from "./components/static_map_entity"; -import { StorageComponent } from "./components/storage"; -import { UndergroundBeltComponent } from "./components/underground_belt"; -import { WiredPinsComponent } from "./components/wired_pins"; -import { WireComponent } from "./components/wire"; -import { ConstantSignalComponent } from "./components/constant_signal"; -import { LogicGateComponent } from "./components/logic_gate"; -import { LeverComponent } from "./components/lever"; -import { WireTunnelComponent } from "./components/wire_tunnel"; -import { DisplayComponent } from "./components/display"; -import { BeltReaderComponent } from "./components/belt_reader"; -import { FilterComponent } from "./components/filter"; -import { ItemProducerComponent } from "./components/item_producer"; -import { GoalAcceptorComponent } from "./components/goal_acceptor"; -/* typehints:end */ - -/** - * Typedefs for all entity components. These are not actually present on the entity, - * thus they are undefined by default - */ -export class EntityComponentStorage { - constructor() { - /* typehints:start */ - - /** @type {StaticMapEntityComponent} */ - this.StaticMapEntity; - - /** @type {BeltComponent} */ - this.Belt; - - /** @type {ItemEjectorComponent} */ - this.ItemEjector; - - /** @type {ItemAcceptorComponent} */ - this.ItemAcceptor; - - /** @type {MinerComponent} */ - this.Miner; - - /** @type {ItemProcessorComponent} */ - this.ItemProcessor; - - /** @type {UndergroundBeltComponent} */ - this.UndergroundBelt; - - /** @type {HubComponent} */ - this.Hub; - - /** @type {StorageComponent} */ - this.Storage; - - /** @type {WiredPinsComponent} */ - this.WiredPins; - - /** @type {BeltUnderlaysComponent} */ - this.BeltUnderlays; - - /** @type {WireComponent} */ - this.Wire; - - /** @type {ConstantSignalComponent} */ - this.ConstantSignal; - - /** @type {LogicGateComponent} */ - this.LogicGate; - - /** @type {LeverComponent} */ - this.Lever; - - /** @type {WireTunnelComponent} */ - this.WireTunnel; - - /** @type {DisplayComponent} */ - this.Display; - - /** @type {BeltReaderComponent} */ - this.BeltReader; - - /** @type {FilterComponent} */ - this.Filter; - - /** @type {ItemProducerComponent} */ - this.ItemProducer; - - /** @type {GoalAcceptorComponent} */ - this.GoalAcceptor; - - /* typehints:end */ - } -} diff --git a/src/js/game/hud/base_hud_part.js b/src/js/game/hud/base_hud_part.js index 91b3fd3a..4c9ae059 100644 --- a/src/js/game/hud/base_hud_part.js +++ b/src/js/game/hud/base_hud_part.js @@ -101,7 +101,7 @@ export class BaseHUDPart { /** * Helper method to construct a new click detector * @param {Element} element The element to listen on - * @param {function} handler The handler to call on this object + * @param {import("../../core/signal").SignalReceiver<[]>} handler The handler to call on this object * @param {import("../../core/click_detector").ClickDetectorConstructorArgs=} args Click detector arguments * */ diff --git a/src/js/game/hud/hud.js b/src/js/game/hud/hud.js index 74214f7d..5aa07f96 100644 --- a/src/js/game/hud/hud.js +++ b/src/js/game/hud/hud.js @@ -34,17 +34,18 @@ export class GameHUD { */ initialize() { this.signals = { - buildingSelectedForPlacement: /** @type {TypedSignal<[MetaBuilding|null]>} */ (new Signal()), - selectedPlacementBuildingChanged: /** @type {TypedSignal<[MetaBuilding|null]>} */ (new Signal()), - shapePinRequested: /** @type {TypedSignal<[ShapeDefinition]>} */ (new Signal()), - shapeUnpinRequested: /** @type {TypedSignal<[string]>} */ (new Signal()), - notification: /** @type {TypedSignal<[string, enumNotificationType]>} */ (new Signal()), - buildingsSelectedForCopy: /** @type {TypedSignal<[Array]>} */ (new Signal()), - pasteBlueprintRequested: /** @type {TypedSignal<[]>} */ (new Signal()), - viewShapeDetailsRequested: /** @type {TypedSignal<[ShapeDefinition]>} */ (new Signal()), - unlockNotificationFinished: /** @type {TypedSignal<[]>} */ (new Signal()), + buildingSelectedForPlacement: /** @type {Signal<[MetaBuilding|null]>} */ (new Signal()), + selectedPlacementBuildingChanged: /** @type {Signal<[MetaBuilding|null]>} */ (new Signal()), + shapePinRequested: /** @type {Signal<[ShapeDefinition]>} */ (new Signal()), + shapeUnpinRequested: /** @type {Signal<[string]>} */ (new Signal()), + notification: /** @type {Signal<[string, enumNotificationType]>} */ (new Signal()), + buildingsSelectedForCopy: /** @type {Signal<[Array]>} */ (new Signal()), + pasteBlueprintRequested: /** @type {Signal<[]>} */ (new Signal()), + viewShapeDetailsRequested: /** @type {Signal<[ShapeDefinition]>} */ (new Signal()), + unlockNotificationFinished: /** @type {Signal<[]>} */ (new Signal()), }; + /** @type {import("./hud_parts").HudParts} */ this.parts = { buildingsToolbar: new HUDBuildingsToolbar(this.root), diff --git a/src/js/game/hud/hud_parts.d.ts b/src/js/game/hud/hud_parts.d.ts new file mode 100644 index 00000000..b6ac3124 --- /dev/null +++ b/src/js/game/hud/hud_parts.d.ts @@ -0,0 +1,107 @@ +import type { HUDBetaOverlay } from "./parts/beta_overlay.js"; +import type { HUDBlueprintPlacer } from "./parts/blueprint_placer.js"; +import type { HUDBuildingsToolbar } from "./parts/buildings_toolbar.js"; +import type { HUDBuildingPlacer } from "./parts/building_placer.js"; +import type { HUDColorBlindHelper } from "./parts/color_blind_helper.js"; +import type { HUDConstantSignalEdit } from "./parts/constant_signal_edit.js"; +import type { HUDChangesDebugger } from "./parts/debug_changes.js"; +import type { HUDDebugInfo } from "./parts/debug_info.js"; +import type { HUDEntityDebugger } from "./parts/entity_debugger.js"; +import type { HUDGameMenu } from "./parts/game_menu.js"; +import type { HUDInteractiveTutorial } from "./parts/interactive_tutorial.js"; +import type { HUDKeybindingOverlay } from "./parts/keybinding_overlay.js"; +import type { HUDLayerPreview } from "./parts/layer_preview.js"; +import type { HUDLeverToggle } from "./parts/lever_toggle.js"; +import type { HUDMassSelector } from "./parts/mass_selector.js"; +import type { HUDMinerHighlight } from "./parts/miner_highlight.js"; +import type { HUDModalDialogs } from "./parts/modal_dialogs.js"; +import type { HUDPuzzleNextPuzzle } from "./parts/next_puzzle.js"; +import type { HUDNotifications } from "./parts/notifications.js"; +import type { HUDPinnedShapes } from "./parts/pinned_shapes.js"; +import type { HUDPuzzleBackToMenu } from "./parts/puzzle_back_to_menu.js"; +import type { HUDPuzzleCompleteNotification } from "./parts/puzzle_complete_notification.js"; +import type { HUDPuzzleDLCLogo } from "./parts/puzzle_dlc_logo.js"; +import type { HUDPuzzleEditorControls } from "./parts/puzzle_editor_controls.js"; +import type { HUDPuzzleEditorReview } from "./parts/puzzle_editor_review.js"; +import type { HUDPuzzleEditorSettings } from "./parts/puzzle_editor_settings.js"; +import type { HUDPuzzlePlayMetadata } from "./parts/puzzle_play_metadata.js"; +import type { HUDPuzzlePlaySettings } from "./parts/puzzle_play_settings.js"; +import type { HUDScreenshotExporter } from "./parts/screenshot_exporter.js"; +import type { HUDSettingsMenu } from "./parts/settings_menu.js"; +import type { HUDShapeTooltip } from "./parts/shape_tooltip.js"; +import type { HUDShapeViewer } from "./parts/shape_viewer.js"; +import type { HUDShop } from "./parts/shop.js"; +import type { HUDStandaloneAdvantages } from "./parts/standalone_advantages.js"; +import type { HUDStatistics } from "./parts/statistics.js"; +import type { HUDPartTutorialHints } from "./parts/tutorial_hints.js"; +import type { HUDTutorialVideoOffer } from "./parts/tutorial_video_offer.js"; +import type { HUDUnlockNotification } from "./parts/unlock_notification.js"; +import type { HUDVignetteOverlay } from "./parts/vignette_overlay.js"; +import type { HUDWatermark } from "./parts/watermark.js"; +import type { HUDWaypoints } from "./parts/waypoints.js"; +import type { HUDWiresOverlay } from "./parts/wires_overlay.js"; +import type { HUDWiresToolbar } from "./parts/wires_toolbar.js"; +import type { HUDWireInfo } from "./parts/wire_info.js"; + +export interface HudParts { + buildingsToolbar: HUDBuildingsToolbar; + + blueprintPlacer: HUDBlueprintPlacer; + buildingPlacer: HUDBuildingPlacer; + + shapeTooltip: HUDShapeTooltip; + + // Must always exist + settingsMenu: HUDSettingsMenu; + debugInfo: HUDDebugInfo; + dialogs: HUDModalDialogs; + + // Dev + entityDebugger?: HUDEntityDebugger; + changesDebugger?: HUDChangesDebugger; + + vignetteOverlay?: HUDVignetteOverlay; + colorBlindHelper?: HUDColorBlindHelper; + betaOverlay?: HUDBetaOverlay; + + // Additional Hud Parts + // Shared + massSelector?: HUDMassSelector; + constantSignalEdit?: HUDConstantSignalEdit; + + // Regular + wiresToolbar?: HUDWiresToolbar; + unlockNotification?: HUDUnlockNotification; + shop?: HUDShop; + statistics?: HUDStatistics; + waypoints?: HUDWaypoints; + wireInfo?: HUDWireInfo; + leverToggle?: HUDLeverToggle; + pinnedShapes?: HUDPinnedShapes; + notifications?: HUDNotifications; + screenshotExporter?: HUDScreenshotExporter; + wiresOverlay?: HUDWiresOverlay; + shapeViewer?: HUDShapeViewer; + layerPreview?: HUDLayerPreview; + minerHighlight?: HUDMinerHighlight; + tutorialVideoOffer?: HUDTutorialVideoOffer; + gameMenu?: HUDGameMenu; + keybindingOverlay?: HUDKeybindingOverlay; + watermark?: HUDWatermark; + standaloneAdvantages?: HUDStandaloneAdvantages; + tutorialHints?: HUDPartTutorialHints; + interactiveTutorial?: HUDInteractiveTutorial; + + // Puzzle mode + puzzleBackToMenu?: HUDPuzzleBackToMenu; + puzzleDlcLogo?: HUDPuzzleDLCLogo; + + puzzleEditorControls?: HUDPuzzleEditorControls; + puzzleEditorReview?: HUDPuzzleEditorReview; + puzzleEditorSettings?: HUDPuzzleEditorSettings; + + puzzlePlayMetadata?: HUDPuzzlePlayMetadata; + puzzlePlaySettings?: HUDPuzzlePlaySettings; + puzzleCompleteNotification?: HUDPuzzleCompleteNotification; + puzzleNext?: HUDPuzzleNextPuzzle; +} diff --git a/src/js/game/hud/parts/constant_signal_edit.js b/src/js/game/hud/parts/constant_signal_edit.js index c2dc6ce3..4d5be119 100644 --- a/src/js/game/hud/parts/constant_signal_edit.js +++ b/src/js/game/hud/parts/constant_signal_edit.js @@ -68,7 +68,7 @@ export class HUDConstantSignalEdit extends BaseHUDPart { label: fillInLinkIntoTranslation(T.dialogs.editSignal.descShortKey, THIRDPARTY_URLS.shapeViewer), placeholder: "", defaultValue: signal ? signal.getAsCopyableKey() : "", - validator: val => this.parseSignalCode(entity, val), + validator: val => this.parseSignalCode(entity, val) !== null, }); const items = [...Object.values(COLOR_ITEM_SINGLETONS)]; diff --git a/src/js/game/hud/parts/modal_dialogs.js b/src/js/game/hud/parts/modal_dialogs.js index 0492d8c0..1e0f1bba 100644 --- a/src/js/game/hud/parts/modal_dialogs.js +++ b/src/js/game/hud/parts/modal_dialogs.js @@ -58,7 +58,7 @@ export class HUDModalDialogs extends BaseHUDPart { /** * @param {string} title * @param {string} text - * @param {Array} buttons + * @param {Array<`${string}:${string}`>} buttons */ showInfo(title, text, buttons = ["ok:good"]) { const dialog = new Dialog({ @@ -80,7 +80,7 @@ export class HUDModalDialogs extends BaseHUDPart { /** * @param {string} title * @param {string} text - * @param {Array} buttons + * @param {Array>} buttons */ showWarning(title, text, buttons = ["ok:good"]) { const dialog = new Dialog({ diff --git a/src/js/game/hud/parts/puzzle_editor_review.js b/src/js/game/hud/parts/puzzle_editor_review.js index df688e0e..9a7fd0b6 100644 --- a/src/js/game/hud/parts/puzzle_editor_review.js +++ b/src/js/game/hud/parts/puzzle_editor_review.js @@ -146,7 +146,7 @@ export class HUDPuzzleEditorReview extends BaseHUDPart { }); itemInput.valueChosen.add(value => { - shapeKeyInput.setValue(value.definition.getHash()); + shapeKeyInput.setValue(/** @type {ShapeItem} */ (value).definition.getHash()); }); this.root.hud.parts.dialogs.internalShowDialog(dialog); diff --git a/src/js/game/hud/parts/puzzle_editor_settings.js b/src/js/game/hud/parts/puzzle_editor_settings.js index 4a23f005..6ef03f23 100644 --- a/src/js/game/hud/parts/puzzle_editor_settings.js +++ b/src/js/game/hud/parts/puzzle_editor_settings.js @@ -150,9 +150,7 @@ export class HUDPuzzleEditorSettings extends BaseHUDPart { } for (const key in building.components) { - /** @type {import("../../../core/global_registries").Component} */ ( - building.components[key] - ).copyAdditionalStateTo(result.components[key]); + building.components[key].copyAdditionalStateTo(result.components[key]); } } }); diff --git a/src/js/game/modes/puzzle_play.js b/src/js/game/modes/puzzle_play.js index c037894c..89ea99b2 100644 --- a/src/js/game/modes/puzzle_play.js +++ b/src/js/game/modes/puzzle_play.js @@ -177,7 +177,7 @@ export class PuzzlePlayGameMode extends PuzzleGameMode { } ); - return new Promise(resolve => { + return new /** @type {typeof Promise} */ (Promise)(resolve => { optionSelected.add(option => { const closeLoading = this.root.hud.parts.dialogs.showLoadingDialog(); diff --git a/src/js/game/root.js b/src/js/game/root.js index 64004e9d..746548a3 100644 --- a/src/js/game/root.js +++ b/src/js/game/root.js @@ -145,53 +145,53 @@ export class GameRoot { this.signals = { // Entities - entityManuallyPlaced: /** @type {TypedSignal<[Entity]>} */ (new Signal()), - entityAdded: /** @type {TypedSignal<[Entity]>} */ (new Signal()), - entityChanged: /** @type {TypedSignal<[Entity]>} */ (new Signal()), - entityGotNewComponent: /** @type {TypedSignal<[Entity]>} */ (new Signal()), - entityComponentRemoved: /** @type {TypedSignal<[Entity]>} */ (new Signal()), - entityQueuedForDestroy: /** @type {TypedSignal<[Entity]>} */ (new Signal()), - entityDestroyed: /** @type {TypedSignal<[Entity]>} */ (new Signal()), + entityManuallyPlaced: /** @type {Signal<[Entity]>} */ (new Signal()), + entityAdded: /** @type {Signal<[Entity]>} */ (new Signal()), + entityChanged: /** @type {Signal<[Entity]>} */ (new Signal()), + entityGotNewComponent: /** @type {Signal<[Entity]>} */ (new Signal()), + entityComponentRemoved: /** @type {Signal<[Entity]>} */ (new Signal()), + entityQueuedForDestroy: /** @type {Signal<[Entity]>} */ (new Signal()), + entityDestroyed: /** @type {Signal<[Entity]>} */ (new Signal()), // Global - resized: /** @type {TypedSignal<[number, number]>} */ (new Signal()), - readyToRender: /** @type {TypedSignal<[]>} */ (new Signal()), - aboutToDestruct: /** @type {TypedSignal<[]>} */ new Signal(), + resized: /** @type {Signal<[number, number]>} */ (new Signal()), + readyToRender: /** @type {Signal<[]>} */ (new Signal()), + aboutToDestruct: /** @type {Signal<[]>} */ new Signal(), // Game Hooks - gameSaved: /** @type {TypedSignal<[]>} */ (new Signal()), // Game got saved - gameRestored: /** @type {TypedSignal<[]>} */ (new Signal()), // Game got restored + gameSaved: /** @type {Signal<[]>} */ (new Signal()), // Game got saved + gameRestored: /** @type {Signal<[]>} */ (new Signal()), // Game got restored - gameFrameStarted: /** @type {TypedSignal<[]>} */ (new Signal()), // New frame + gameFrameStarted: /** @type {Signal<[]>} */ (new Signal()), // New frame - storyGoalCompleted: /** @type {TypedSignal<[number, string]>} */ (new Signal()), - upgradePurchased: /** @type {TypedSignal<[string]>} */ (new Signal()), + storyGoalCompleted: /** @type {Signal<[number, string]>} */ (new Signal()), + upgradePurchased: /** @type {Signal<[string]>} */ (new Signal()), // Called right after game is initialized - postLoadHook: /** @type {TypedSignal<[]>} */ (new Signal()), + postLoadHook: /** @type {Signal<[]>} */ (new Signal()), - shapeDelivered: /** @type {TypedSignal<[ShapeDefinition]>} */ (new Signal()), - itemProduced: /** @type {TypedSignal<[BaseItem]>} */ (new Signal()), + shapeDelivered: /** @type {Signal<[ShapeDefinition]>} */ (new Signal()), + itemProduced: /** @type {Signal<[BaseItem]>} */ (new Signal()), - bulkOperationFinished: /** @type {TypedSignal<[]>} */ (new Signal()), - immutableOperationFinished: /** @type {TypedSignal<[]>} */ (new Signal()), + bulkOperationFinished: /** @type {Signal<[]>} */ (new Signal()), + immutableOperationFinished: /** @type {Signal<[]>} */ (new Signal()), - editModeChanged: /** @type {TypedSignal<[Layer]>} */ (new Signal()), + editModeChanged: /** @type {Signal<[Layer]>} */ (new Signal()), // Called to check if an entity can be placed, second parameter is an additional offset. // Use to introduce additional placement checks - prePlacementCheck: /** @type {TypedSignal<[Entity, Vector]>} */ (new Signal()), + prePlacementCheck: /** @type {Signal<[Entity, Vector]>} */ (new Signal()), // Called before actually placing an entity, use to perform additional logic // for freeing space before actually placing. - freeEntityAreaBeforeBuild: /** @type {TypedSignal<[Entity]>} */ (new Signal()), + freeEntityAreaBeforeBuild: /** @type {Signal<[Entity]>} */ (new Signal()), // Called with an achievement key and necessary args to validate it can be unlocked. - achievementCheck: /** @type {TypedSignal<[string, any]>} */ (new Signal()), - bulkAchievementCheck: /** @type {TypedSignal<(string|any)[]>} */ (new Signal()), + achievementCheck: /** @type {Signal<[string, any]>} */ (new Signal()), + bulkAchievementCheck: /** @type {Signal<(string|any)[]>} */ (new Signal()), // Puzzle mode - puzzleComplete: /** @type {TypedSignal<[]>} */ (new Signal()), + puzzleComplete: /** @type {Signal<[]>} */ (new Signal()), }; // RNG's diff --git a/src/js/game/systems/item_processor.js b/src/js/game/systems/item_processor.js index 50e3e9ba..a5ff5c97 100644 --- a/src/js/game/systems/item_processor.js +++ b/src/js/game/systems/item_processor.js @@ -48,17 +48,17 @@ const MAX_QUEUED_CHARGES = 2; */ /** - * @type {Object void>} + * @type {Object void>} */ export const MOD_ITEM_PROCESSOR_HANDLERS = {}; /** - * @type {Object boolean>} + * @type {Object boolean>} */ export const MODS_PROCESSING_REQUIREMENTS = {}; /** - * @type {Object boolean>} + * @type {Object boolean>} */ export const MODS_CAN_PROCESS = {}; @@ -67,7 +67,7 @@ export class ItemProcessorSystem extends GameSystemWithFilter { super(root, [ItemProcessorComponent]); /** - * @type {Object} + * @type {Object void>} */ this.handlers = { [enumItemProcessorTypes.balancer]: this.process_BALANCER, diff --git a/src/js/game/time/base_game_speed.js b/src/js/game/time/base_game_speed.js index 8898b6cf..926ebc11 100644 --- a/src/js/game/time/base_game_speed.js +++ b/src/js/game/time/base_game_speed.js @@ -21,8 +21,7 @@ export class BaseGameSpeed extends BasicSerializableObject { } getId() { - // @ts-ignore - return this.constructor.getId(); + return /** @type {typeof BaseGameSpeed} */ (this.constructor).getId(); } static getSchema() { diff --git a/src/js/game/time/game_time.js b/src/js/game/time/game_time.js index 07b224a7..94ceb53d 100644 --- a/src/js/game/time/game_time.js +++ b/src/js/game/time/game_time.js @@ -180,7 +180,10 @@ export class GameTime extends BasicSerializableObject { setSpeed(speed) { assert(speed instanceof BaseGameSpeed, "Not a valid game speed"); if (this.speed.getId() === speed.getId()) { - logger.warn("Same speed set than current one:", speed.constructor.getId()); + logger.warn( + "Same speed set than current one:", + /** @type {typeof BaseGameSpeed} */ (speed.constructor).getId() + ); } this.speed = speed; } diff --git a/src/js/globals.d.ts b/src/js/globals.d.ts index cf553e2d..706b83c7 100644 --- a/src/js/globals.d.ts +++ b/src/js/globals.d.ts @@ -1,8 +1,11 @@ // Globals defined by webpack declare const G_IS_DEV: boolean; -declare function assert(condition: boolean | object | string, ...errorMessage: string[]): void; -declare function assertAlways(condition: boolean | object | string, ...errorMessage: string[]): void; +declare function assert(condition: boolean | object | string, ...errorMessage: string[]): asserts condition; +declare function assertAlways( + condition: boolean | object | string, + ...errorMessage: string[] +): asserts condition; declare const abstract: void; @@ -142,34 +145,6 @@ declare interface String { padEnd(size: number, fill: string): string; } -declare interface FactoryTemplate { - entries: Array>; - entryIds: Array; - idToEntry: any; - - getId(): string; - getAllIds(): Array; - register(entry: Class): void; - hasId(id: string): boolean; - findById(id: string): Class; - getEntries(): Array>; - getNumEntries(): number; -} - -declare interface SingletonFactoryTemplate { - entries: Array; - idToEntry: any; - - getId(): string; - getAllIds(): Array; - register(classHandle: Class): void; - hasId(id: string): boolean; - findById(id: string): T; - findByClass(classHandle: Class): T; - getEntries(): Array; - getNumEntries(): number; -} - declare interface SignalTemplate0 { add(receiver: () => string | void, scope: null | any); dispatch(): string | void; @@ -186,18 +161,6 @@ declare class TypedTrackedState { get(): T; } -declare const STOP_PROPAGATION = "stop_propagation"; - -declare interface TypedSignal> { - add(receiver: (...args: T) => /* STOP_PROPAGATION */ string | void, scope?: object); - addToTop(receiver: (...args: T) => /* STOP_PROPAGATION */ string | void, scope?: object); - remove(receiver: (...args: T) => /* STOP_PROPAGATION */ string | void); - - dispatch(...args: T): /* STOP_PROPAGATION */ string | void; - - removeAll(); -} - declare type Layer = "regular" | "wires"; declare type ItemType = "shape" | "color" | "boolean"; diff --git a/src/js/mods/mod_signals.js b/src/js/mods/mod_signals.js index f5824190..d858da99 100644 --- a/src/js/mods/mod_signals.js +++ b/src/js/mods/mod_signals.js @@ -13,27 +13,27 @@ export const MOD_SIGNALS = { // Called when the application has booted and instances like the app settings etc are available appBooted: new Signal(), - modifyLevelDefinitions: /** @type {TypedSignal<[Array[Object]]>} */ (new Signal()), - modifyUpgrades: /** @type {TypedSignal<[Object]>} */ (new Signal()), + modifyLevelDefinitions: /** @type {Signal<[Array[Object]]>} */ (new Signal()), + modifyUpgrades: /** @type {Signal<[Object]>} */ (new Signal()), - hudElementInitialized: /** @type {TypedSignal<[BaseHUDPart]>} */ (new Signal()), - hudElementFinalized: /** @type {TypedSignal<[BaseHUDPart]>} */ (new Signal()), + hudElementInitialized: /** @type {Signal<[BaseHUDPart]>} */ (new Signal()), + hudElementFinalized: /** @type {Signal<[BaseHUDPart]>} */ (new Signal()), - hudInitializer: /** @type {TypedSignal<[GameRoot]>} */ (new Signal()), + hudInitializer: /** @type {Signal<[GameRoot]>} */ (new Signal()), - gameInitialized: /** @type {TypedSignal<[GameRoot]>} */ (new Signal()), - gameLoadingStageEntered: /** @type {TypedSignal<[InGameState, string]>} */ (new Signal()), + gameInitialized: /** @type {Signal<[GameRoot]>} */ (new Signal()), + gameLoadingStageEntered: /** @type {Signal<[InGameState, string]>} */ (new Signal()), - gameStarted: /** @type {TypedSignal<[GameRoot]>} */ (new Signal()), + gameStarted: /** @type {Signal<[GameRoot]>} */ (new Signal()), - stateEntered: /** @type {TypedSignal<[GameState]>} */ (new Signal()), + stateEntered: /** @type {Signal<[GameState]>} */ (new Signal()), gameSerialized: - /** @type {TypedSignal<[GameRoot, import("../savegame/savegame_typedefs").SerializedGame]>} */ ( + /** @type {Signal<[GameRoot, import("../savegame/savegame_typedefs").SerializedGame]>} */ ( new Signal() ), gameDeserialized: - /** @type {TypedSignal<[GameRoot, import("../savegame/savegame_typedefs").SerializedGame]>} */ ( + /** @type {Signal<[GameRoot, import("../savegame/savegame_typedefs").SerializedGame]>} */ ( new Signal() ), }; diff --git a/src/js/mods/modloader.js b/src/js/mods/modloader.js index fc699a1a..3104bca3 100644 --- a/src/js/mods/modloader.js +++ b/src/js/mods/modloader.js @@ -107,9 +107,13 @@ export class ModLoader { exposeExports() { if (G_IS_DEV || G_IS_STANDALONE) { let exports = {}; - const modules = import.meta.webpackContext("../", { recursive: true, regExp: /\.js$/ }); + const modules = import.meta.webpackContext("../", { + recursive: true, + regExp: /\.[jt]s$/, + exclude: /\.d\.ts$/, + }); Array.from(modules.keys()).forEach(key => { - // @ts-ignore + /** @type {object} */ const module = modules(key); for (const member in module) { if (member === "default" || member === "__$S__") { diff --git a/src/js/platform/browser/game_analytics.js b/src/js/platform/browser/game_analytics.js index f13b8d03..bb48d214 100644 --- a/src/js/platform/browser/game_analytics.js +++ b/src/js/platform/browser/game_analytics.js @@ -110,11 +110,6 @@ export class ShapezGameAnalytics extends GameAnalyticsInterface { initialize() { this.syncKey = null; - window.setAbt = abt => { - this.app.storage.writeFileAsync("shapez_" + CURRENT_ABT + ".bin", String(abt)); - window.location.reload(); - }; - // Retrieve sync key from player return this.fetchABVariant().then(() => { setInterval(() => this.sendTimePoints(), 60 * 1000); diff --git a/src/js/platform/browser/wrapper.js b/src/js/platform/browser/wrapper.js index 94d174f3..7e34e5d0 100644 --- a/src/js/platform/browser/wrapper.js +++ b/src/js/platform/browser/wrapper.js @@ -140,7 +140,7 @@ export class PlatformWrapperImplBrowser extends PlatformWrapperInterface { performRestart() { logger.log("Performing restart"); - window.location.reload(true); + window.location.reload(); } /** diff --git a/src/js/platform/electron/wrapper.js b/src/js/platform/electron/wrapper.js index 64cc8253..6ef6de93 100644 --- a/src/js/platform/electron/wrapper.js +++ b/src/js/platform/electron/wrapper.js @@ -54,7 +54,7 @@ export class PlatformWrapperImplElectron extends PlatformWrapperImplBrowser { performRestart() { logger.log(this, "Performing restart"); - window.location.reload(true); + window.location.reload(); } initializeAdProvider() { diff --git a/src/js/savegame/schemas/1006.js b/src/js/savegame/schemas/1006.js index 50dd4bbe..7806df8f 100644 --- a/src/js/savegame/schemas/1006.js +++ b/src/js/savegame/schemas/1006.js @@ -1,4 +1,4 @@ -import { gMetaBuildingRegistry } from "../../core/global_registries.js"; +import { gMetaBuildingRegistry } from "../../core/global_registries"; import { createLogger } from "../../core/logging.js"; import { enumBalancerVariants, MetaBalancerBuilding } from "../../game/buildings/balancer.js"; import { MetaBeltBuilding } from "../../game/buildings/belt.js"; diff --git a/src/js/savegame/serialization.js b/src/js/savegame/serialization.js index 682558b6..c2f24707 100644 --- a/src/js/savegame/serialization.js +++ b/src/js/savegame/serialization.js @@ -25,6 +25,15 @@ import { TypePositiveIntegerOrString, } from "./serialization_data_types"; +/** + * @typedef {import("../core/factory").Factory} FactoryTemplate + * @template T + */ +/** + * @typedef {import("../core/singleton_factory").SingletonFactory} SingletonFactoryTemplate + * @template {{ getId(): string }} T + */ + const logger = createLogger("serialization"); // Schema declarations @@ -106,7 +115,7 @@ export const types = { }, /** - * @param {SingletonFactoryTemplate<*>} innerType + * @param {SingletonFactoryTemplate<*>} registry */ classRef(registry) { return new TypeMetaClass(registry); diff --git a/src/js/savegame/serialization_data_types.js b/src/js/savegame/serialization_data_types.js index c27e2295..4e79f2da 100644 --- a/src/js/savegame/serialization_data_types.js +++ b/src/js/savegame/serialization_data_types.js @@ -7,6 +7,15 @@ import { Vector } from "../core/vector"; import { round4Digits } from "../core/utils"; export const globalJsonSchemaDefs = {}; +/** + * @typedef {import("../core/factory").Factory} FactoryTemplate + * @template T + */ +/** + * @typedef {import("../core/singleton_factory").SingletonFactory} SingletonFactoryTemplate + * @template {{ getId(): string }} T + */ + /** * * @param {import("./serialization").Schema} schema @@ -48,6 +57,7 @@ export class BaseDataType { /** * Serializes a given raw value * @param {any} value + * @returns {unknown} * @abstract */ serialize(value) { @@ -1034,7 +1044,8 @@ export class TypeKeyValueMap extends BaseDataType { const serialized = this.valueType.serialize(value[key]); if (!this.includeEmptyValues && typeof serialized === "object") { if ( - serialized.$ && + "$" in serialized && + "data" in serialized && typeof serialized.data === "object" && Object.keys(serialized.data).length === 0 ) { diff --git a/src/js/states/keybindings.js b/src/js/states/keybindings.js index e6721bf8..06c3142c 100644 --- a/src/js/states/keybindings.js +++ b/src/js/states/keybindings.js @@ -103,7 +103,11 @@ export class KeybindingsState extends TextualGameState { event.preventDefault(); } - if (event.target && event.target.tagName === "BUTTON" && keyCode === 1) { + if ( + event.target && + /** @type {HTMLElement} */ (event.target).tagName === "BUTTON" && + keyCode === 1 + ) { return; } diff --git a/src/js/states/login.js b/src/js/states/login.js index cd8d8007..a529fe92 100644 --- a/src/js/states/login.js +++ b/src/js/states/login.js @@ -54,7 +54,9 @@ export class LoginState extends GameState { T.dialogs.offlineMode.desc, ["retry", "playOffline:bad"] ); - signals.retry.add(() => setTimeout(() => this.tryLogin(), 2000), this); + signals.retry.add(() => { + setTimeout(() => this.tryLogin(), 2000); + }, this); signals.playOffline.add(this.finishLoading, this); } else { this.finishLoading(); diff --git a/src/js/states/main_menu.js b/src/js/states/main_menu.js index c69f4ff5..2c64f275 100644 --- a/src/js/states/main_menu.js +++ b/src/js/states/main_menu.js @@ -798,7 +798,7 @@ export class MainMenuState extends GameState { "continue:bad", ]); - return new Promise(resolve => { + return new /** @type {typeof Promise} */ (Promise)(resolve => { signals.continue.add(resolve); }); } diff --git a/src/js/states/preload.js b/src/js/states/preload.js index ed742a57..430c0ba7 100644 --- a/src/js/states/preload.js +++ b/src/js/states/preload.js @@ -265,7 +265,7 @@ export class PreloadState extends GameState { `; } - return new Promise(resolve => { + return new /** @type {typeof Promise} */ (Promise)(resolve => { this.dialogs.showInfo(T.dialogs.updateSummary.title, dialogHtml).ok.add(resolve); }); }); diff --git a/src/js/tsconfig.json b/src/js/tsconfig.json index 423282e5..44b23525 100644 --- a/src/js/tsconfig.json +++ b/src/js/tsconfig.json @@ -30,6 +30,7 @@ /* Module Resolution Options */ "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, + "types": ["webpack/module"] /* Type declaration files to be included in compilation. */, "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, "paths": { "root/*": ["./*"]