From 224bc6c7e586eea46342348fa882b701d1caac04 Mon Sep 17 00:00:00 2001 From: tobspr Date: Sat, 16 May 2020 17:57:25 +0200 Subject: [PATCH] Add ability to import savegames, add game menu, multiple smaller improvements --- res/ui/demo_badge.png | Bin 0 -> 3544 bytes res/ui/get_on_steam.png | Bin 0 -> 38720 bytes res/ui/icons/delete.png | Bin 0 -> 825 bytes src/css/ingame_hud/dialogs.scss | 30 +- src/css/ingame_hud/game_menu.scss | 4 +- src/css/ingame_hud/notifications.scss | 4 +- src/css/ingame_hud/settings_menu.scss | 32 ++ src/css/ingame_hud/shop.scss | 3 + src/css/ingame_hud/statistics.scss | 4 + src/css/main.scss | 16 +- src/css/states/main_menu.scss | 104 ++++- src/js/application.js | 8 +- src/js/core/buffer_maintainer.js | 2 +- src/js/core/config.js | 2 +- src/js/core/modal_dialog_elements.js | 430 ++++++++++++++++++ src/js/core/modal_dialog_forms.js | 150 ++++++ src/js/core/read_write_proxy.js | 29 ++ src/js/core/utils.js | 8 +- src/js/game/components/underground_belt.js | 2 - src/js/game/hud/hud.js | 2 + src/js/game/hud/parts/game_menu.js | 5 + src/js/game/hud/parts/mass_selector.js | 2 +- src/js/game/hud/parts/modal_dialogs.js | 188 ++++++++ src/js/game/hud/parts/notifications.js | 4 +- src/js/game/hud/parts/settings_menu.js | 97 ++++ src/js/platform/browser/storage.js | 1 + src/js/platform/browser/storage_indexed_db.js | 155 +++++++ src/js/savegame/savegame.js | 11 +- src/js/savegame/savegame_manager.js | 16 + src/js/states/main_menu.js | 152 ++++++- src/js/states/preload.js | 8 +- 31 files changed, 1422 insertions(+), 47 deletions(-) create mode 100644 res/ui/demo_badge.png create mode 100644 res/ui/get_on_steam.png create mode 100644 res/ui/icons/delete.png create mode 100644 src/css/ingame_hud/settings_menu.scss create mode 100644 src/js/core/modal_dialog_elements.js create mode 100644 src/js/core/modal_dialog_forms.js create mode 100644 src/js/game/hud/parts/modal_dialogs.js create mode 100644 src/js/game/hud/parts/settings_menu.js create mode 100644 src/js/platform/browser/storage_indexed_db.js diff --git a/res/ui/demo_badge.png b/res/ui/demo_badge.png new file mode 100644 index 0000000000000000000000000000000000000000..6c80db7d9b8c2c70cb2445d474b94ad97b2ebaa9 GIT binary patch literal 3544 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 Ct|FBH literal 0 HcmV?d00001 diff --git a/res/ui/icons/delete.png b/res/ui/icons/delete.png new file mode 100644 index 0000000000000000000000000000000000000000..db1c86f1613dc8d9df46c589e69d66d3682a6719 GIT binary patch literal 825 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I0wfs{c7_5;mUKs7M+SzC{oH>NS%G|oWRD45bDP46hOx7_4S6Fo+k-*%fF5lweBoc6VW5Sk*X)|5VtD5P1xoXAaZ&7c* z?fs>;+V0h&t#RpF><_E2W3vC@Fl9nTqoW?<=9u!`cJu#FRC&3xs5kJd+oY1~i)}O9 zE-hM{U+On;$@XotqI+1TH)d|n^(?KQ!M^SXs|9aot$g^G{VnbdXG$J0tUb0z&~Kvo z^7c8DcM zeo7X`9xw=)!C@R*sb|8KaxZkE-}nLm>d{57#I~8SQ=m)cdiA( z9r9aB8fE6DPMmXV^`=>YXU~5wwY@yO>&6VVReehFD>DAu*l%eMQLXv+wRvup`74GU zv2|6f5nh}OK9#H$^LMqZ%RGJCuyn~gwFRZ|^?PsRzY3fDb{S)Dc4_=G*4~x+Cm+kJ zUb5M`>HO=J3=9maC9V-ADTyViR>?)FK#IZ0z{o(?z*N`JEX2^%%GlV-)I{6Bz{nC}Q!>*kacj7#7UB%lAPKS|I6tkVJh3R1p}f3YFEcN@I61K(RWH9NefB#W QDWD<-Pgg&ebxsLQ00>Js9{>OV literal 0 HcmV?d00001 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}
+ ` + } +