diff --git a/res/ui/demo_badge.png b/res/ui/demo_badge.png new file mode 100644 index 00000000..6c80db7d Binary files /dev/null and b/res/ui/demo_badge.png differ diff --git a/res/ui/get_on_steam.png b/res/ui/get_on_steam.png new file mode 100644 index 00000000..f276031c Binary files /dev/null and b/res/ui/get_on_steam.png differ diff --git a/res/ui/icons/delete.png b/res/ui/icons/delete.png new file mode 100644 index 00000000..db1c86f1 Binary files /dev/null and b/res/ui/icons/delete.png differ diff --git a/src/css/ingame_hud/dialogs.scss b/src/css/ingame_hud/dialogs.scss index 6ebc6f8f..be97da96 100644 --- a/src/css/ingame_hud/dialogs.scss +++ b/src/css/ingame_hud/dialogs.scss @@ -22,9 +22,15 @@ opacity: 0; } + &.loadingDialog { + * { + color: #fff; + } + } + > .dialogInner { background: #fff; - @include S(min-width, 500px); + @include S(min-width, 300px); max-width: calc(100vw - #{D(40px)}); max-height: calc(100vh - #{D(40px)}); @include S(border-radius, 4px); @@ -60,5 +66,27 @@ overflow-y: auto; pointer-events: all; } + + > .buttons { + @include S(margin-top, 15px); + display: flex; + justify-content: flex-end; + > button { + @include S(margin-left, 8px); + @include Text; + @include S(min-width, 60px); + @include S(padding, 5px, 15px); + + &.good { + background-color: $colorGreenBright; + color: #fff; + } + + &.bad { + background-color: $colorRedBright; + color: #fff; + } + } + } } } diff --git a/src/css/ingame_hud/game_menu.scss b/src/css/ingame_hud/game_menu.scss index 61da1eff..10531c4a 100644 --- a/src/css/ingame_hud/game_menu.scss +++ b/src/css/ingame_hud/game_menu.scss @@ -24,6 +24,8 @@ transition-property: opacity, transform; opacity: 0.9; @include S(margin-left, 5px); + position: relative; + @include IncreasedClickArea(0px); &:hover { opacity: 0.8; @@ -80,7 +82,7 @@ border-radius: 0 0 #{D(4px)} #{D(4px)}; @include S(padding-left, 30px); @include S(margin-right, 3px); - @include IncreasedClickArea(10px); + @include IncreasedClickArea(0px); @include ButtonText; @include S(min-height, 30px); transition: all 0.12s ease-in-out; diff --git a/src/css/ingame_hud/notifications.scss b/src/css/ingame_hud/notifications.scss index 36e56e28..fcae4d04 100644 --- a/src/css/ingame_hud/notifications.scss +++ b/src/css/ingame_hud/notifications.scss @@ -21,8 +21,8 @@ } transform-origin: 100% 50%; - - @include InlineAnimation(5s ease-in-out) { + opacity: 0; + @include InlineAnimation(3s ease-in-out) { 0% { opacity: 1; } diff --git a/src/css/ingame_hud/settings_menu.scss b/src/css/ingame_hud/settings_menu.scss new file mode 100644 index 00000000..ade1fda1 --- /dev/null +++ b/src/css/ingame_hud/settings_menu.scss @@ -0,0 +1,32 @@ +#ingame_HUD_SettingsMenu { + .timePlayed { + position: absolute; + @include S(left, 30px); + @include S(bottom, 30px); + color: #fff; + display: flex; + flex-direction: column; + strong { + text-transform: uppercase; + @include PlainText; + } + + span { + @include Heading; + } + } + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + .buttons { + display: grid; + grid-auto-flow: row; + @include S(grid-gap, 10px); + background: rgba(0, 10, 20, 0.1); + @include S(padding, 20px); + @include S(border-radius, 2px); + } +} diff --git a/src/css/ingame_hud/shop.scss b/src/css/ingame_hud/shop.scss index 3cb2d31d..2e79341d 100644 --- a/src/css/ingame_hud/shop.scss +++ b/src/css/ingame_hud/shop.scss @@ -3,6 +3,8 @@ @include S(padding-right, 10px); display: flex; flex-direction: column; + @include S(width, 500px); + .upgrade { display: grid; grid-template-columns: auto 1fr auto; @@ -99,6 +101,7 @@ display: flex; flex-direction: column; align-items: center; + @include S(width, 65px); button.pin { @include S(width, 12px); diff --git a/src/css/ingame_hud/statistics.scss b/src/css/ingame_hud/statistics.scss index 7d1967a1..a431c012 100644 --- a/src/css/ingame_hud/statistics.scss +++ b/src/css/ingame_hud/statistics.scss @@ -1,4 +1,8 @@ #ingame_HUD_Statistics { + .content { + @include S(width, 500px); + } + .filterHeader { display: grid; grid-template-columns: auto 1fr; diff --git a/src/css/main.scss b/src/css/main.scss index c2668158..f6692cde 100644 --- a/src/css/main.scss +++ b/src/css/main.scss @@ -37,12 +37,13 @@ @import "ingame_hud/statistics"; @import "ingame_hud/pinned_shapes"; @import "ingame_hud/notifications"; +@import "ingame_hud/settings_menu"; // Z-Index $elements: ingame_Canvas, ingame_VignetteOverlay, ingame_HUD_building_placer, ingame_HUD_PinnedShapes, ingame_HUD_buildings_toolbar, ingame_HUD_GameMenu, ingame_HUD_KeybindingOverlay, ingame_HUD_Notifications, ingame_HUD_Shop, ingame_HUD_Statistics, ingame_HUD_BetaOverlay, ingame_HUD_MassSelector, - ingame_HUD_UnlockNotification; + ingame_HUD_UnlockNotification, ingame_HUD_SettingsMenu; $zindex: 100; @@ -59,16 +60,15 @@ body.uiHidden { #ingame_HUD_building_placer, #ingame_HUD_GameMenu, #ingame_HUD_MassSelector, - #ingame_HUD_PinnedShapes { + #ingame_HUD_PinnedShapes, + #ingame_HUD_Notifications { display: none !important; } } + +body.modalDialogActive, body.ingameDialogOpen { - #ingame_Canvas, - #ingame_HUD_GameMenu, - #ingame_HUD_KeybindingOverlay, - #ingame_HUD_buildings_toolbar, - #ingame_HUD_PinnedShapes { - filter: blur(5px); + > *:not(.ingameDialog):not(.modalDialogParent) { + filter: blur(5px) !important; } } diff --git a/src/css/states/main_menu.scss b/src/css/states/main_menu.scss index 458bda6f..4c1422bd 100644 --- a/src/css/states/main_menu.scss +++ b/src/css/states/main_menu.scss @@ -5,7 +5,6 @@ flex-direction: column; background: rgb(140, 165, 194) center center / cover !important; - // background: $colorGreenBright !important; .fullscreenBackgroundVideo { z-index: -1; @@ -36,14 +35,88 @@ } } + .mainWrapper { + display: grid; + grid-template-columns: 1fr auto 1fr; + @include S(padding, 0, 10px); + align-items: center; + justify-items: center; + @include S(grid-column-gap, 10px); + + .standaloneBanner { + background: rgb(255, 225, 238); + @include S(border-radius, 4px); + height: 100%; + box-sizing: border-box; + @include S(padding, 15px); + + display: flex; + flex-direction: column; + + strong { + font-weight: bold; + } + + h3 { + @include Heading; + font-weight: bold; + @include S(margin-bottom, 15px); + text-transform: uppercase; + color: $colorRedBright; + } + + p { + @include Text; + } + + ul { + @include S(margin-top, 15px); + @include S(padding-left, 20px); + li { + @include Text; + } + } + + .steamLink { + width: 100%; + @include S(height, 50px); + + background: uiResource("get_on_steam.png") center center / contain no-repeat; + overflow: hidden; + display: block; + text-indent: -999em; + cursor: pointer; + @include S(margin-top, 20px); + pointer-events: all; + transition: all 0.12s ease-in; + transition-property: opacity, transform; + transform: skewX(-0.5deg); + &:hover { + transform: skewX(-1deg) scale(1.02); + opacity: 0.9; + } + } + } + } + .logo { display: flex; flex-grow: 1; align-items: center; justify-content: center; + flex-direction: column; + @include S(padding-top, 20px); img { @include S(width, 350px); } + + .demoBadge { + @include S(margin, 10px, 0); + @include S(width, 100px); + @include S(height, 30px); + background: uiResource("demo_badge.png") center center / contain no-repeat; + display: inline-block; + } } .betaWarning { @@ -53,12 +126,11 @@ @include S(padding, 10px); @include S(border-radius, 4px); color: #fff; - @include S(margin-bottom, 10px); + @include S(margin-top, 10px); border: #{D(2px)} solid rgba(0, 10, 20, 0.1); } .mainContainer { - @include S(margin-top, 10px); display: flex; align-items: center; justify-content: flex-start; @@ -67,6 +139,8 @@ @include S(padding, 20px); @include S(border-radius, 4px); // border: #{D(2px)} solid rgba(0, 10, 20, 0.1); + height: 100%; + box-sizing: border-box; .playButton { @include SuperHeading; @@ -82,8 +156,12 @@ } } + .importButton { + @include S(margin-top, 15px); + } + .savegames { - @include S(max-height, 92px); + @include S(max-height, 105px); overflow-y: auto; @include S(width, 250px); pointer-events: all; @@ -101,6 +179,7 @@ grid-template-columns: 1fr auto auto; grid-template-rows: auto auto; @include S(grid-column-gap, 5px); + @include S(grid-row-gap, 3px); .internalId { grid-column: 1 / 2; @@ -116,7 +195,8 @@ } button.resumeGame, - button.downloadGame { + button.downloadGame, + button.deleteGame { grid-column: 3 / 4; grid-row: 1 / 3; @include S(width, 30px); @@ -128,8 +208,22 @@ button.downloadGame { grid-column: 2 / 3; + grid-row: 1 / 2; background-image: uiResource("icons/download.png"); @include S(width, 15px); + @include IncreasedClickArea(0px); + @include S(height, 15px); + align-self: end; + background-size: 60%; + } + + button.deleteGame { + grid-column: 2 / 3; + grid-row: 2 / 3; + background-color: $colorRedBright; + @include IncreasedClickArea(0px); + background-image: uiResource("icons/delete.png"); + @include S(width, 15px); @include S(height, 15px); align-self: end; background-size: 60%; diff --git a/src/js/application.js b/src/js/application.js index 9f1bf55e..1d80c7ce 100644 --- a/src/js/application.js +++ b/src/js/application.js @@ -30,6 +30,7 @@ import { GameAnalyticsInterface } from "./platform/game_analytics"; import { ShapezGameAnalytics } from "./platform/browser/game_analytics"; import { queryParamOptions } from "./core/query_parameters"; import { NoGameAnalytics } from "./platform/browser/no_game_analytics"; +import { StorageImplBrowserIndexedDB } from "./platform/browser/storage_indexed_db"; const logger = createLogger("application"); @@ -119,7 +120,12 @@ export class Application { // Start with empty ad provider this.adProvider = new NoAdProvider(this); - this.storage = new StorageImplBrowser(this); + + if (window.indexedDB) { + this.storage = new StorageImplBrowserIndexedDB(this); + } else { + this.storage = new StorageImplBrowser(this); + } this.sound = new SoundImplBrowser(this); this.platformWrapper = new PlatformWrapperImplBrowser(this); this.analytics = new GoogleAnalyticsImpl(this); diff --git a/src/js/core/buffer_maintainer.js b/src/js/core/buffer_maintainer.js index 8421cbc5..c92a92a5 100644 --- a/src/js/core/buffer_maintainer.js +++ b/src/js/core/buffer_maintainer.js @@ -13,7 +13,7 @@ import { round1Digit } from "./utils"; const logger = createLogger("buffers"); -const bufferGcDurationSeconds = 3; +const bufferGcDurationSeconds = 10; export class BufferMaintainer { /** diff --git a/src/js/core/config.js b/src/js/core/config.js index 2af8972f..5413f91c 100644 --- a/src/js/core/config.js +++ b/src/js/core/config.js @@ -32,7 +32,7 @@ export const globalConfig = { // Map mapChunkSize: 32, - mapChunkPrerenderMinZoom: 1.5, + mapChunkPrerenderMinZoom: 1.3, mapChunkOverviewMinZoom: 0.7, // Belt speeds diff --git a/src/js/core/modal_dialog_elements.js b/src/js/core/modal_dialog_elements.js new file mode 100644 index 00000000..701fdf1d --- /dev/null +++ b/src/js/core/modal_dialog_elements.js @@ -0,0 +1,430 @@ +/* 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"; + +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 seperated 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 seperated 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.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 + */ + handleKeydown({ keyCode, shift, alt }) { + 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.dialog_buttons[buttonId]; + button.innerText = 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); + }, 5000); + 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) { + 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; + + const loader = document.createElement("div"); + loader.classList.add("prefab_LoadingTextWithAnim"); + loader.classList.add("loadingIndicator"); + loader.innerText = "Loading"; + 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 {string=} param0.confirmButton + * @param {Array} param0.formElements + */ + constructor({ app, title, desc, formElements, confirmButton = "ok:good" }) { + let html = ""; + html += desc + "
"; + for (let i = 0; i < formElements.length; ++i) { + html += formElements[i].getHtml(); + } + + super({ + app, + title: title, + contentHTML: html, + buttons: ["cancel:bad", confirmButton], + type: "info", + closeButton: true, + }); + this.confirmButtonId = confirmButton.split(":")[0]; + this.formElements = formElements; + } + + 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); + } + + waitNextFrame().then(() => { + this.formElements[0].focus(); + }); + + return div; + } +} diff --git a/src/js/core/modal_dialog_forms.js b/src/js/core/modal_dialog_forms.js new file mode 100644 index 00000000..4d1c9f97 --- /dev/null +++ b/src/js/core/modal_dialog_forms.js @@ -0,0 +1,150 @@ +import { ClickDetector } from "./click_detector"; + +export class FormElement { + constructor(id, label) { + this.id = id; + this.label = label; + } + + getHtml() { + abstract; + return ""; + } + + getFormElement(parent) { + return parent.querySelector("[data-formId='" + this.id + "']"); + } + + bindEvents(parent, clickTrackers) { + abstract; + } + + focus(parent) {} + + 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; + } + + focus() { + this.element.focus(); + } +} + +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) {} +} diff --git a/src/js/core/read_write_proxy.js b/src/js/core/read_write_proxy.js index b0f16704..4a10f140 100644 --- a/src/js/core/read_write_proxy.js +++ b/src/js/core/read_write_proxy.js @@ -89,6 +89,35 @@ export class ReadWriteProxy { return compressionPrefix + compressX64(checksum + jsonString); } + /** + * + * @param {object} text + */ + static deserializeObject(text) { + const decompressed = decompressX64(text.substr(compressionPrefix.length)); + if (!decompressed) { + // LZ string decompression failure + throw new Error("bad-content / decompression-failed"); + } + if (decompressed.length < 40) { + // String too short + throw new Error("bad-content / payload-too-small"); + } + + // Compare stored checksum with actual checksum + const checksum = decompressed.substring(0, 40); + const jsonString = decompressed.substr(40); + const desiredChecksum = sha1(jsonString + salt); + if (desiredChecksum !== checksum) { + // Checksum mismatch + throw new Error("bad-content / checksum-mismatch"); + } + + const parsed = JSON.parse(jsonString); + const decoded = decompressObject(parsed); + return decoded; + } + /** * Writes the data asychronously, fails if verify() fails * @returns {Promise} diff --git a/src/js/core/utils.js b/src/js/core/utils.js index 34279769..fe869920 100644 --- a/src/js/core/utils.js +++ b/src/js/core/utils.js @@ -668,9 +668,11 @@ export function makeButton(parent, classes = [], innerHTML = "") { * @param {Element} elem */ export function removeAllChildren(elem) { - var range = document.createRange(); - range.selectNodeContents(elem); - range.deleteContents(); + if (elem) { + var range = document.createRange(); + range.selectNodeContents(elem); + range.deleteContents(); + } } export function smartFadeNumber(current, newOne, minFade = 0.01, maxFade = 0.9) { diff --git a/src/js/game/components/underground_belt.js b/src/js/game/components/underground_belt.js index 0215ade7..fb90db23 100644 --- a/src/js/game/components/underground_belt.js +++ b/src/js/game/components/underground_belt.js @@ -57,7 +57,6 @@ export class UndergroundBeltComponent extends Component { return false; } - console.log("Takes", 1 / beltSpeed); this.pendingItems.push([item, 1 / beltSpeed]); return true; } @@ -85,7 +84,6 @@ export class UndergroundBeltComponent extends Component { // This corresponds to the item ejector - it needs 0.5 additional tiles to eject the item. // So instead of adding 1 we add 0.5 only. const travelDuration = (travelDistance + 0.5) / beltSpeed; - console.log(travelDistance, "->", travelDuration); this.pendingItems.push([item, travelDuration]); diff --git a/src/js/game/hud/hud.js b/src/js/game/hud/hud.js index 2dacb3cb..9b2e7993 100644 --- a/src/js/game/hud/hud.js +++ b/src/js/game/hud/hud.js @@ -20,6 +20,7 @@ import { MetaBuilding } from "../meta_building"; import { HUDPinnedShapes } from "./parts/pinned_shapes"; import { ShapeDefinition } from "../shape_definition"; import { HUDNotifications, enumNotificationType } from "./parts/notifications"; +import { HUDSettingsMenu } from "./parts/settings_menu"; export class GameHUD { /** @@ -53,6 +54,7 @@ export class GameHUD { pinnedShapes: new HUDPinnedShapes(this.root), notifications: new HUDNotifications(this.root), + settingsMenu: new HUDSettingsMenu(this.root), // betaOverlay: new HUDBetaOverlay(this.root), }; diff --git a/src/js/game/hud/parts/game_menu.js b/src/js/game/hud/parts/game_menu.js index 0a80af16..f024de3b 100644 --- a/src/js/game/hud/parts/game_menu.js +++ b/src/js/game/hud/parts/game_menu.js @@ -71,6 +71,7 @@ export class HUDGameMenu extends BaseHUDPart { this.trackClicks(this.musicButton, this.toggleMusic); this.trackClicks(this.sfxButton, this.toggleSfx); this.trackClicks(this.saveButton, this.startSave); + this.trackClicks(this.settingsButton, this.openSettings); this.musicButton.classList.toggle("muted", this.root.app.settings.getAllSettings().musicMuted); this.sfxButton.classList.toggle("muted", this.root.app.settings.getAllSettings().soundsMuted); @@ -117,6 +118,10 @@ export class HUDGameMenu extends BaseHUDPart { this.root.gameState.doSave(); } + openSettings() { + this.root.hud.parts.settingsMenu.show(); + } + toggleMusic() { const newValue = !this.root.app.settings.getAllSettings().musicMuted; this.root.app.settings.updateSetting("musicMuted", newValue); diff --git a/src/js/game/hud/parts/mass_selector.js b/src/js/game/hud/parts/mass_selector.js index f54d1e3c..25e37306 100644 --- a/src/js/game/hud/parts/mass_selector.js +++ b/src/js/game/hud/parts/mass_selector.js @@ -61,7 +61,7 @@ export class HUDMassSelector extends BaseHUDPart { */ onBack() { // Clear entities on escape - if (this.entityUidsMarkedForDeletion) { + if (this.entityUidsMarkedForDeletion.size > 0) { this.entityUidsMarkedForDeletion = new Set(); return STOP_PROPAGATION; } diff --git a/src/js/game/hud/parts/modal_dialogs.js b/src/js/game/hud/parts/modal_dialogs.js new file mode 100644 index 00000000..e163d551 --- /dev/null +++ b/src/js/game/hud/parts/modal_dialogs.js @@ -0,0 +1,188 @@ +/* typehints:start */ +import { Application } from "../../../application"; +/* typehints:end */ + +import { SOUNDS } from "../../../platform/sound"; +import { DynamicDomAttach } from "../dynamic_dom_attach"; +import { BaseHUDPart } from "../base_hud_part"; +import { + Dialog, + DialogLoading, + DialogVideoTutorial, + DialogOptionChooser, +} from "../../../core/modal_dialog_elements"; +import { makeDiv } from "../../../core/utils"; + +export class HUDModalDialogs extends BaseHUDPart { + constructor(root, app) { + // Important: Root is not always available here! Its also used in the main menu + super(root); + + /** @type {Application} */ + this.app = app; + + this.dialogParent = null; + this.dialogStack = []; + } + + // For use inside of the game, implementation of base hud part + initialize() { + this.dialogParent = document.getElementById("rg_HUD_ModalDialogs"); + this.domWatcher = new DynamicDomAttach(this.root, this.dialogParent); + } + + shouldPauseRendering() { + return this.dialogStack.length > 0; + } + + shouldPauseGame() { + return this.shouldPauseRendering(); + } + + createElements(parent) { + return makeDiv(parent, "rg_HUD_ModalDialogs"); + } + + // For use outside of the game + initializeToElement(element) { + assert(element, "No element for dialogs given"); + this.dialogParent = element; + } + + // Methods + + showInfo(title, text, buttons = ["ok:good"]) { + const dialog = new Dialog({ + app: this.app, + title: title, + contentHTML: text, + buttons: buttons, + type: "info", + }); + this.internalShowDialog(dialog); + + if (this.app) { + this.app.sound.playUiSound(SOUNDS.dialogOk); + } + + return dialog.buttonSignals; + } + + showWarning(title, text, buttons = ["ok:good"]) { + const dialog = new Dialog({ + app: this.app, + title: title, + contentHTML: text, + buttons: buttons, + type: "warning", + }); + this.internalShowDialog(dialog); + + if (this.app) { + this.app.sound.playUiSound(SOUNDS.dialogError); + } + + return dialog.buttonSignals; + } + + showVideoTutorial(title, text, videoUrl) { + const dialog = new DialogVideoTutorial({ + app: this.app, + title: title, + contentHTML: text, + videoUrl, + }); + this.internalShowDialog(dialog); + + if (this.app) { + this.app.sound.playUiSound(SOUNDS.dialogOk); + } + + return dialog.buttonSignals; + } + + showOptionChooser(title, options) { + const dialog = new DialogOptionChooser({ + app: this.app, + title, + options, + }); + this.internalShowDialog(dialog); + return dialog.buttonSignals; + } + + // Returns method to be called when laoding finishd + showLoadingDialog() { + const dialog = new DialogLoading(this.app); + this.internalShowDialog(dialog); + return this.closeDialog.bind(this, dialog); + } + + internalShowDialog(dialog) { + const elem = dialog.createElement(); + dialog.setIndex(this.dialogStack.length); + + // Hide last dialog in queue + if (this.dialogStack.length > 0) { + this.dialogStack[this.dialogStack.length - 1].hide(); + } + + this.dialogStack.push(dialog); + + // Append dialog + dialog.show(); + dialog.closeRequested.add(this.closeDialog.bind(this, dialog)); + + // Append to HTML + this.dialogParent.appendChild(elem); + + document.body.classList.toggle("modalDialogActive", this.dialogStack.length > 0); + + // IMPORTANT: Attach element directly, otherwise double submit is possible + this.update(); + } + + update() { + if (this.domWatcher) { + this.domWatcher.update(this.dialogStack.length > 0); + } + } + + closeDialog(dialog) { + dialog.destroy(); + + let index = -1; + for (let i = 0; i < this.dialogStack.length; ++i) { + if (this.dialogStack[i] === dialog) { + index = i; + break; + } + } + assert(index >= 0, "Dialog not in dialog stack"); + this.dialogStack.splice(index, 1); + + if (this.dialogStack.length > 0) { + // Show the dialog which was previously open + this.dialogStack[this.dialogStack.length - 1].show(); + } + + document.body.classList.toggle("modalDialogActive", this.dialogStack.length > 0); + } + + close() { + for (let i = 0; i < this.dialogStack.length; ++i) { + const dialog = this.dialogStack[i]; + dialog.destroy(); + } + this.dialogStack = []; + } + + cleanup() { + super.cleanup(); + for (let i = 0; i < this.dialogStack.length; ++i) { + this.dialogStack[i].destroy(); + } + this.dialogStack = []; + this.dialogParent = null; + } +} diff --git a/src/js/game/hud/parts/notifications.js b/src/js/game/hud/parts/notifications.js index eee61b8e..5916d6b8 100644 --- a/src/js/game/hud/parts/notifications.js +++ b/src/js/game/hud/parts/notifications.js @@ -8,6 +8,8 @@ export const enumNotificationType = { success: "success", }; +const notificationDuration = 3; + export class HUDNotifications extends BaseHUDPart { createElements(parent) { this.element = makeDiv(parent, "ingame_HUD_Notifications", [], ``); @@ -35,7 +37,7 @@ export class HUDNotifications extends BaseHUDPart { this.notificationElements.push({ element, - expireAt: this.root.time.realtimeNow() + 5, + expireAt: this.root.time.realtimeNow() + notificationDuration, }); } diff --git a/src/js/game/hud/parts/settings_menu.js b/src/js/game/hud/parts/settings_menu.js new file mode 100644 index 00000000..dc547e32 --- /dev/null +++ b/src/js/game/hud/parts/settings_menu.js @@ -0,0 +1,97 @@ +import { BaseHUDPart } from "../base_hud_part"; +import { makeDiv } from "../../../core/utils"; +import { DynamicDomAttach } from "../dynamic_dom_attach"; +import { InputReceiver } from "../../../core/input_receiver"; +import { KeyActionMapper } from "../../key_action_mapper"; + +export class HUDSettingsMenu extends BaseHUDPart { + createElements(parent) { + this.background = makeDiv(parent, "ingame_HUD_SettingsMenu", ["ingameDialog"]); + + this.menuElement = makeDiv(this.background, null, ["menuElement"]); + + this.timePlayed = makeDiv( + this.background, + null, + ["timePlayed"], + `Playtime` + ); + + this.buttonContainer = makeDiv(this.menuElement, null, ["buttons"]); + + const buttons = [ + { + title: "Continue", + action: () => this.close(), + }, + { + title: "Return to menu", + action: () => this.returnToMenu(), + }, + ]; + + for (let i = 0; i < buttons.length; ++i) { + const { title, action } = buttons[i]; + + const element = document.createElement("button"); + element.classList.add("styledButton"); + element.innerText = title; + this.buttonContainer.appendChild(element); + + this.trackClicks(element, action); + } + } + + returnToMenu() { + this.root.gameState.goBackToMenu(); + } + + shouldPauseGame() { + return this.visible; + } + + shouldPauseRendering() { + return this.visible; + } + + initialize() { + this.root.gameState.keyActionMapper.getBinding("back").add(this.show, this); + + this.domAttach = new DynamicDomAttach(this.root, this.background, { + attachClass: "visible", + }); + + this.inputReciever = new InputReceiver("settingsmenu"); + this.keyActionMapper = new KeyActionMapper(this.root, this.inputReciever); + + this.keyActionMapper.getBinding("back").add(this.close, this); + + this.close(); + } + + cleanup() { + document.body.classList.remove("ingameDialogOpen"); + } + + show() { + this.visible = true; + document.body.classList.add("ingameDialogOpen"); + // this.background.classList.add("visible"); + this.root.app.inputMgr.makeSureAttachedAndOnTop(this.inputReciever); + + const totalMinutesPlayed = Math.ceil(this.root.time.now() / 60.0); + const playtimeString = totalMinutesPlayed === 1 ? "1 minute" : totalMinutesPlayed + " minutes"; + this.timePlayed.querySelector(".playtime").innerText = playtimeString; + } + + close() { + this.visible = false; + document.body.classList.remove("ingameDialogOpen"); + this.root.app.inputMgr.makeSureDetached(this.inputReciever); + this.update(); + } + + update() { + this.domAttach.update(this.visible); + } +} diff --git a/src/js/platform/browser/storage.js b/src/js/platform/browser/storage.js index 23cbe700..2a399e54 100644 --- a/src/js/platform/browser/storage.js +++ b/src/js/platform/browser/storage.js @@ -20,6 +20,7 @@ export class StorageImplBrowser extends StorageInterface { } initialize() { + logger.error("Using localStorage, please update to a newer browser"); return new Promise((resolve, reject) => { // Check for local storage availability in general if (!window.localStorage) { diff --git a/src/js/platform/browser/storage_indexed_db.js b/src/js/platform/browser/storage_indexed_db.js new file mode 100644 index 00000000..10479c4f --- /dev/null +++ b/src/js/platform/browser/storage_indexed_db.js @@ -0,0 +1,155 @@ +import { FILE_NOT_FOUND, StorageInterface } from "../storage"; +import { createLogger } from "../../core/logging"; + +const logger = createLogger("storage/browserIDB"); + +const LOCAL_STORAGE_UNAVAILABLE = "local-storage-unavailable"; +const LOCAL_STORAGE_NO_WRITE_PERMISSION = "local-storage-no-write-permission"; + +let randomDelay = () => 0; + +if (G_IS_DEV) { + // Random delay for testing + // randomDelay = () => 500; +} + +export class StorageImplBrowserIndexedDB extends StorageInterface { + constructor(app) { + super(app); + this.currentBusyFilename = false; + + /** @type {IDBDatabase} */ + this.database = null; + } + + initialize() { + logger.log("Using indexed DB storage"); + return new Promise((resolve, reject) => { + const request = window.indexedDB.open("app_storage", 10); + request.onerror = event => { + logger.error("IDB error:", event); + reject("Indexed DB access error"); + }; + + request.onsuccess = event => resolve(event.target.result); + + request.onupgradeneeded = /** @type {IDBVersionChangeEvent} */ event => { + /** @type {IDBDatabase} */ + const database = event.target.result; + + const objectStore = database.createObjectStore("files", { + keyPath: "filename", + }); + + objectStore.createIndex("filename", "filename", { unique: true }); + + objectStore.transaction.onerror = event => { + logger.error("IDB transaction error:", event); + reject("Indexed DB transaction error during migration, check console output."); + }; + + objectStore.transaction.oncomplete = event => { + logger.log("Object store completely initialized"); + resolve(database); + }; + }; + }).then(database => { + this.database = database; + }); + } + + writeFileAsync(filename, contents) { + if (this.currentBusyFilename === filename) { + logger.warn("Attempt to write", filename, "while write process is not finished!"); + } + if (!this.database) { + return Promise.reject("Storage not ready"); + } + + this.currentBusyFilename = filename; + const transaction = this.database.transaction(["files"], "readwrite"); + + return new Promise((resolve, reject) => { + transaction.oncomplete = () => { + this.currentBusyFilename = null; + resolve(); + }; + + transaction.onerror = error => { + this.currentBusyFilename = null; + logger.error("Error while writing", filename, ":", error); + reject(error); + }; + + const store = transaction.objectStore("files"); + store.put({ + filename, + contents, + }); + }); + } + + writeFileSyncIfSupported(filename, contents) { + // Not supported + this.writeFileAsync(filename, contents); + return true; + } + + readFileAsync(filename) { + if (!this.database) { + return Promise.reject("Storage not ready"); + } + + this.currentBusyFilename = filename; + const transaction = this.database.transaction(["files"], "readonly"); + + return new Promise((resolve, reject) => { + const store = transaction.objectStore("files"); + const request = store.get(filename); + + request.onsuccess = event => { + this.currentBusyFilename = null; + if (!request.result) { + reject(FILE_NOT_FOUND); + return; + } + resolve(request.result.contents); + }; + + request.onerror = error => { + this.currentBusyFilename = null; + logger.error("Error while reading", filename, ":", error); + reject(error); + }; + }); + } + + deleteFileAsync(filename) { + if (this.currentBusyFilename === filename) { + logger.warn("Attempt to delete", filename, "while write progres on it is ongoing!"); + } + + if (!this.database) { + return Promise.reject("Storage not ready"); + } + + this.currentBusyFilename = filename; + const transaction = this.database.transaction(["files"], "readwrite"); + + return new Promise((resolve, reject) => { + transaction.oncomplete = () => { + this.currentBusyFilename = null; + resolve(); + }; + + transaction.onerror = error => { + this.currentBusyFilename = null; + logger.error("Error while deleting", filename, ":", error); + reject(error); + }; + + const store = transaction.objectStore("files"); + store.delete(filename); + }); + } +} diff --git a/src/js/savegame/savegame.js b/src/js/savegame/savegame.js index 55286b93..8b9d2b3b 100644 --- a/src/js/savegame/savegame.js +++ b/src/js/savegame/savegame.js @@ -76,13 +76,9 @@ export class Savegame extends ReadWriteProxy { * @param {SavegameData} data */ migrate(data) { - // if (data.version === 1014) { - // if (data.dump) { - // const reader = new SavegameInterface_V1015(fakeLogger, data); - // reader.migrateFrom1014(); - // } - // data.version = 1015; - // } + if (data.version < 1000) { + return ExplainedResult.bad("Can not migrate savegame, too old"); + } return ExplainedResult.good(); } @@ -218,7 +214,6 @@ export class Savegame extends ReadWriteProxy { * Updates the savegames metadata */ saveMetadata() { - const reader = this.getDumpReader(); this.metaDataRef.lastUpdate = new Date().getTime(); this.metaDataRef.version = this.getCurrentVersion(); return this.app.savegameMgr.writeAsync(); diff --git a/src/js/savegame/savegame_manager.js b/src/js/savegame/savegame_manager.js index b3c0d735..6b63d721 100644 --- a/src/js/savegame/savegame_manager.js +++ b/src/js/savegame/savegame_manager.js @@ -154,6 +154,22 @@ export class SavegameManager extends ReadWriteProxy { }); } + importSavegame(data) { + const savegame = this.createNewSavegame(); + const migrationResult = savegame.migrate(data); + if (migrationResult.isBad()) { + return Promise.reject("Failed to migrate: " + migrationResult.reason); + } + + savegame.currentData = data; + const verification = savegame.verify(data); + if (verification.isBad()) { + return Promise.reject("Verification failed: " + verification.result); + } + + return savegame.writeSavegameAndMetadata().then(() => this.sortSavegames()); + } + /** * Sorts all savegames by their creation time descending * @returns {Promise} diff --git a/src/js/states/main_menu.js b/src/js/states/main_menu.js index 8dfc8c83..efaba60b 100644 --- a/src/js/states/main_menu.js +++ b/src/js/states/main_menu.js @@ -1,8 +1,15 @@ import { GameState } from "../core/game_state"; import { cachebust } from "../core/cachebust"; import { globalConfig } from "../core/config"; -import { makeDiv, formatSecondsToTimeAgo, generateFileDownload } from "../core/utils"; +import { + makeDiv, + formatSecondsToTimeAgo, + generateFileDownload, + removeAllChildren, + waitNextFrame, +} from "../core/utils"; import { ReadWriteProxy } from "../core/read_write_proxy"; +import { HUDModalDialogs } from "../game/hud/parts/modal_dialogs"; export class MainMenuState extends GameState { constructor() { @@ -10,23 +17,62 @@ export class MainMenuState extends GameState { } getInnerHTML() { + const bannerHtml = ` +

This is a Demo Version

+ +

Get shapez.io on steam for:

+ +
    +
  • No advertisements and demo banners.
  • +
  • Unlimited savegame slots.
  • +
  • Supporting the developer ❤️
  • +
+ + Get shapez.io on steam! + `; + return ` + - -
- This game is still under development - Please report any issues! -
-
- +
+ + ${ + G_IS_STANDALONE + ? "" + : ` +
${bannerHtml}
+ ` + } +
+ + +
+ + ${ + G_IS_STANDALONE + ? "" + : ` +
${bannerHtml}
+ ` + } +