diff --git a/src/css/states/main_menu.scss b/src/css/states/main_menu.scss index d61bc9d0..6dacb269 100644 --- a/src/css/states/main_menu.scss +++ b/src/css/states/main_menu.scss @@ -1,507 +1,535 @@ -#state_MainMenuState { - display: flex; - align-items: center; - justify-content: center; - flex-direction: column; - - // background: #aaacb4 center center / cover !important; - background: #bbc2cf center center / cover !important; - - .topButtons { - position: absolute; - @include S(top, 20px); - @include S(right, 20px); - display: grid; - grid-auto-flow: column; - @include S(grid-gap, 15px); - - .settingsButton, - .exitAppButton, - .languageChoose { - @include S(width, 25px); - @include S(height, 25px); - pointer-events: all; - cursor: pointer; - background: uiResource("icons/main_menu_settings.png") center center / contain no-repeat; - transition: opacity 0.12s ease-in-out; - @include IncreasedClickArea(2px); - &:hover { - opacity: 0.9; - } - } - - .exitAppButton { - background-image: uiResource("icons/main_menu_exit.png"); - } - - .languageChoose { - @include S(border-radius, 8px); - border: solid #222428; - background-color: #fff; - @include S(border-width, 2px); - background-size: cover; - } - } - - .fullscreenBackgroundVideo { - // display: none !important; - z-index: -1; - position: fixed; - right: 50%; - bottom: 50%; - min-width: 100%; - min-height: 100%; - - opacity: 0; - display: none; - transform: translate(50%, 50%); - filter: blur(D(3px)); - - $opacity: 0.2; - &.loaded { - display: block; - opacity: $opacity; - - @include InlineAnimation(0.1s ease-in-out) { - 0% { - opacity: 0; - } - 100% { - opacity: $opacity; - } - } - } - } - - .mainWrapper { - @include S(padding, 0, 10px); - align-items: start; - justify-items: center; - - @include S(grid-column-gap, 10px); - display: grid; - grid-template-columns: 1fr; - - &.demo { - grid-template-columns: 1fr 1fr; - } - - .standaloneBanner { - background: rgb(255, 234, 245); - @include S(border-radius, $globalBorderRadius); - box-sizing: border-box; - @include S(padding, 15px); - - display: flex; - flex-direction: column; - - strong { - font-weight: bold; - @include S(margin, 0, 4px); - } - - h3 { - @include Heading; - font-weight: bold; - @include S(margin-bottom, 5px); - text-transform: uppercase; - color: $colorRedBright; - } - - p { - @include Text; - } - - ul { - @include S(margin-top, 5px); - @include S(padding-left, 20px); - li { - @include Text; - } - } - - .steamLink { - width: 100%; - @include S(height, 40px); - - 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; - } - - position: relative; - .updateLabel { - position: absolute; - transform: translateX(50%) rotate(-5deg); - color: $colorRedBright; - @include Heading; - text-transform: uppercase; - font-weight: bold; - @include S(right, 40px); - @include S(bottom, 20px); - - @include InlineAnimation(1.3s ease-in-out infinite) { - 50% { - transform: translateX(50%) rotate(-7deg) scale(1.1); - } - } - - @include DarkThemeOverride { - color: $colorBlueBright; - } - } - } - - .betaWarning { - @include S(width, 400px); - @include PlainText; - background: $colorRedBright; - @include S(padding, 10px); - @include S(border-radius, $globalBorderRadius); - color: #fff; - @include S(margin-top, 10px); - border: #{D(2px)} solid rgba(0, 10, 20, 0.1); - } - - .sideContainer { - display: flex; - flex-direction: column; - @include S(width, 300px); - - .standaloneBanner { - flex-grow: 1; - @include S(margin-bottom, 10px); - } - } - - .mainContainer { - display: flex; - align-items: center; - justify-content: center; - flex-direction: column; - background: #fafafa; - @include S(padding, 20px); - @include S(border-radius, $globalBorderRadius); - // border: #{D(2px)} solid rgba(0, 10, 20, 0.1); - height: 100%; - width: 100%; - box-sizing: border-box; - - .buttons { - display: flex; - flex-direction: column; - align-items: center; - } - - .browserWarning { - @include S(margin-bottom, 10px); - background-color: $colorRedBright; - @include PlainText; - color: #fff; - @include S(border-radius, $globalBorderRadius); - @include S(padding, 5px); - @include S(width, 300px); - } - - .playButton, - .continueButton { - @include SuperHeading; - @include S(min-width, 130px); - @include S(padding, 15px, 20px); - letter-spacing: 0.3em !important; - @include IncreasedClickArea(0px); - font-weight: bold; - color: #fff; - background-color: $colorGreenBright; - transition: transform 0.12s ease-in-out, background-color 0.12s ease-in-out; - - &:hover { - background-color: darken($colorGreenBright, 4); - opacity: 1; - } - - &.continueButton { - @include Heading; - } - } - - .importButton { - @include S(margin-top, 15px); - @include IncreasedClickArea(0px); - } - - .newGameButton { - @include IncreasedClickArea(0px); - @include S(margin-top, 15px); - @include S(margin-left, 15px); - } - - .savegames { - @include S(max-height, 105px); - overflow-y: auto; - @include S(width, 250px); - pointer-events: all; - @include S(padding-right, 5px); - display: grid; - grid-auto-flow: row; - @include S(grid-gap, 5px); - @include S(margin-top, 10px); - - .savegame { - background: #eee; - @include S(border-radius, $globalBorderRadius); - @include S(padding, 5px); - display: grid; - grid-template-columns: 1fr auto auto; - grid-template-rows: auto auto; - @include S(grid-column-gap, 4px); - @include S(grid-row-gap, 1px); - - .playtime { - grid-column: 1 / 2; - grid-row: 2 / 3; - @include SuperSmallText; - opacity: 0.5; - } - - .level { - grid-column: 1 / 2; - grid-row: 1 / 2; - @include PlainText; - } - - button.resumeGame, - button.downloadGame, - button.deleteGame { - padding: 0; - align-self: center; - justify-self: center; - @include IncreasedClickArea(0px); - background: #44484a uiResource("icons/play.png") center center / 40% no-repeat; - } - - button.downloadGame { - grid-column: 2 / 3; - grid-row: 1 / 2; - background-color: $colorBlueBright; - background-image: uiResource("icons/download.png"); - @include S(width, 15px); - @include IncreasedClickArea(0px); - @include S(height, 15px); - background-size: 60%; - align-self: start; - } - - 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%; - } - - button.resumeGame { - grid-column: 3 / 4; - grid-row: 1 / 3; - margin: 0; - @include S(width, 32px); - height: 100%; - } - } - } - } - - .footer { - display: grid; - flex-grow: 1; - justify-content: center; - align-items: flex-end; - width: 100%; - grid-template-columns: auto auto auto 1fr; - @include S(padding, 10px); - box-sizing: border-box; - @include S(grid-gap, 4px); - - .author { - flex-grow: 1; - text-align: right; - @include PlainText; - color: #888a8f; - a { - color: #333438; - } - } - - @include S(padding, 15px); - - > .boxLink { - display: grid; - align-items: center; - grid-template-columns: 1fr auto; - - justify-content: center; - background: #fdfdfd uiResource("icons/link.png") top D(3px) right D(3px) / D(9px) no-repeat; - @include S(padding, 5px); - @include S(padding-left, 10px); - @include S(border-radius, $globalBorderRadius); - @include SuperSmallText(); - - font-weight: bold; - box-sizing: border-box; - text-transform: uppercase; - color: #616266; - - transition: background-color 0.12s ease-in-out; - pointer-events: all; - @include S(width, 120px); - @include S(height, 60px); - - cursor: pointer; - &:hover { - background-color: #f0f6ff; - } - - .thirdpartyLogo { - display: inline-block; - @include S(width, 50px); - @include S(height, 50px); - background: center center / 80% no-repeat; - &.githubLogo { - background-image: uiResource("main_menu/github.png"); - } - &.discordLogo { - background-image: uiResource("main_menu/discord.png"); - background-size: 95%; - } - } - } - - > .sidelinks { - display: grid; - align-items: flex-start; - justify-content: flex-start; - grid-template-rows: 1fr 1fr 1fr; - @include S(grid-gap, 3px); - @include S(height, 60px); - - > a { - color: #616266; - background: #fdfdfd; - height: 100%; - - &:hover { - background-color: #f0f6ff; - } - @include SuperSmallText; - text-transform: uppercase; - width: 100%; - @include S(padding, 2px, 10px); - display: flex; - align-items: center; - justify-content: flex-start; - - @include S(padding-left, 25px); - box-sizing: border-box; - font-weight: bold; - background-position: #{D(5px)} center; - background-size: #{D(12px)}; - background-repeat: no-repeat; - @include S(border-radius, $globalBorderRadius); - - transition: background-color 0.12s ease-in-out; - - &.redditLink { - background-image: uiResource("main_menu/reddit.svg"); - } - &.changelog { - background-image: uiResource("main_menu/changelog.svg"); - } - &.helpTranslate { - background-image: uiResource("main_menu/translate.svg"); - } - } - } - } - - @include DarkThemeOverride { - background: $darkModeGameBackground center center / cover !important; - - .topButtons { - filter: invert(1); - - .languageChoose { - filter: invert(1); - } - } - - .mainContainer { - background: darken($darkModeGameBackground, 10); - - .savegames .savegame { - background: darken($darkModeGameBackground, 15); - color: white; - } - } - - .footer { - > a, - .sidelinks > a { - background-color: darken($darkModeGameBackground, 10); - color: #eee; - - &:hover { - background-color: darken($darkModeGameBackground, 8); - } - } - - .author { - color: #bdbdbd; - - > a { - color: white; - } - } - - .thirdpartyLogo.githubLogo { - filter: invert(1); - } - } - } -} +#state_MainMenuState { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + + // background: #aaacb4 center center / cover !important; + background: #bbc2cf center center / cover !important; + + .topButtons { + position: absolute; + @include S(top, 20px); + @include S(right, 20px); + display: grid; + grid-auto-flow: column; + @include S(grid-gap, 15px); + + .settingsButton, + .exitAppButton, + .languageChoose { + @include S(width, 25px); + @include S(height, 25px); + pointer-events: all; + cursor: pointer; + background: uiResource("icons/main_menu_settings.png") center center / contain no-repeat; + transition: opacity 0.12s ease-in-out; + @include IncreasedClickArea(2px); + &:hover { + opacity: 0.9; + } + } + + .exitAppButton { + background-image: uiResource("icons/main_menu_exit.png"); + } + + .languageChoose { + @include S(border-radius, 8px); + border: solid #222428; + background-color: #fff; + @include S(border-width, 2px); + background-size: cover; + } + } + + .fullscreenBackgroundVideo { + // display: none !important; + z-index: -1; + position: fixed; + right: 50%; + bottom: 50%; + min-width: 100%; + min-height: 100%; + + opacity: 0; + display: none; + transform: translate(50%, 50%); + filter: blur(D(3px)); + + $opacity: 0.2; + &.loaded { + display: block; + opacity: $opacity; + + @include InlineAnimation(0.1s ease-in-out) { + 0% { + opacity: 0; + } + 100% { + opacity: $opacity; + } + } + } + } + + .mainWrapper { + @include S(padding, 0, 10px); + align-items: start; + justify-items: center; + + @include S(grid-column-gap, 10px); + display: grid; + grid-template-columns: 1fr; + + &.demo { + grid-template-columns: 1fr 1fr; + } + + .standaloneBanner { + background: rgb(255, 234, 245); + @include S(border-radius, $globalBorderRadius); + box-sizing: border-box; + @include S(padding, 15px); + + display: flex; + flex-direction: column; + + strong { + font-weight: bold; + @include S(margin, 0, 4px); + } + + h3 { + @include Heading; + font-weight: bold; + @include S(margin-bottom, 5px); + text-transform: uppercase; + color: $colorRedBright; + } + + p { + @include Text; + } + + ul { + @include S(margin-top, 5px); + @include S(padding-left, 20px); + li { + @include Text; + } + } + + .steamLink { + width: 100%; + @include S(height, 40px); + + 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; + } + + position: relative; + .updateLabel { + position: absolute; + transform: translateX(50%) rotate(-5deg); + color: $colorRedBright; + @include Heading; + text-transform: uppercase; + font-weight: bold; + @include S(right, 40px); + @include S(bottom, 20px); + + @include InlineAnimation(1.3s ease-in-out infinite) { + 50% { + transform: translateX(50%) rotate(-7deg) scale(1.1); + } + } + + @include DarkThemeOverride { + color: $colorBlueBright; + } + } + } + + .betaWarning { + @include S(width, 400px); + @include PlainText; + background: $colorRedBright; + @include S(padding, 10px); + @include S(border-radius, $globalBorderRadius); + color: #fff; + @include S(margin-top, 10px); + border: #{D(2px)} solid rgba(0, 10, 20, 0.1); + } + + .sideContainer { + display: flex; + flex-direction: column; + @include S(width, 300px); + + .standaloneBanner { + flex-grow: 1; + @include S(margin-bottom, 10px); + } + } + + .mainContainer { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + background: #fafafa; + @include S(padding, 20px); + @include S(border-radius, $globalBorderRadius); + // border: #{D(2px)} solid rgba(0, 10, 20, 0.1); + height: 100%; + width: 100%; + box-sizing: border-box; + + .buttons { + display: flex; + flex-direction: column; + align-items: center; + } + + .browserWarning { + @include S(margin-bottom, 10px); + background-color: $colorRedBright; + @include PlainText; + color: #fff; + @include S(border-radius, $globalBorderRadius); + @include S(padding, 5px); + @include S(width, 300px); + } + + .playButton, + .continueButton { + @include SuperHeading; + @include S(min-width, 130px); + @include S(padding, 15px, 20px); + letter-spacing: 0.3em !important; + @include IncreasedClickArea(0px); + font-weight: bold; + color: #fff; + background-color: $colorGreenBright; + transition: transform 0.12s ease-in-out, background-color 0.12s ease-in-out; + + &:hover { + background-color: darken($colorGreenBright, 4); + opacity: 1; + } + + &.continueButton { + @include Heading; + } + } + + .importButton { + @include S(margin-top, 15px); + @include IncreasedClickArea(0px); + } + + .newGameButton { + @include IncreasedClickArea(0px); + @include S(margin-top, 15px); + @include S(margin-left, 15px); + } + + .savegames { + @include S(max-height, 105px); + overflow-y: auto; + @include S(width, 250px); + pointer-events: all; + @include S(padding-right, 5px); + display: grid; + grid-auto-flow: row; + @include S(grid-gap, 5px); + @include S(margin-top, 10px); + + .savegame { + background: #eee; + @include S(border-radius, $globalBorderRadius); + @include S(padding, 5px); + display: grid; + grid-template-columns: 1fr 1fr auto auto; + grid-template-rows: auto auto; + @include S(grid-column-gap, 4px); + @include S(grid-row-gap, 1px); + + .playtime { + grid-column: 2 / 3; + grid-row: 2 / 3; + @include SuperSmallText; + opacity: 0.5; + } + + .level { + grid-column: 1 / 2; + grid-row: 2 / 3; + @include SuperSmallText; + opacity: 0.5; + } + + .name { + grid-column: 1 / 3; + grid-row: 1 / 2; + @include PlainText; + display: inline-flex; + align-items: center; + } + + button.resumeGame, + button.downloadGame, + button.deleteGame, + button.renameGame { + padding: 0; + align-self: center; + justify-self: center; + @include IncreasedClickArea(0px); + background: #44484a uiResource("icons/play.png") center center / 40% no-repeat; + } + + button.downloadGame { + grid-column: 3 / 4; + grid-row: 1 / 2; + background-color: $colorBlueBright; + background-image: uiResource("icons/download.png"); + @include S(width, 15px); + @include IncreasedClickArea(0px); + @include S(height, 15px); + background-size: 60%; + align-self: start; + } + + button.deleteGame { + grid-column: 3 / 4; + 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%; + } + + button.renameGame { + background-color: transparent; + @include IncreasedClickArea(2px); + background-image: uiResource("icons/edit_key.png"); + @include S(width, 10px); + @include S(height, 10px); + align-self: center; + justify-self: center; + + background-size: 90%; + opacity: 0.25; + @include S(margin-left, 4px); + + &:hover { + opacity: 0.35; + } + } + + button.resumeGame { + grid-column: 4 / 5; + grid-row: 1 / 3; + margin: 0; + @include S(width, 32px); + height: 100%; + } + } + } + } + + .footer { + display: grid; + flex-grow: 1; + justify-content: center; + align-items: flex-end; + width: 100%; + grid-template-columns: auto auto auto 1fr; + @include S(padding, 10px); + box-sizing: border-box; + @include S(grid-gap, 4px); + + .author { + flex-grow: 1; + text-align: right; + @include PlainText; + color: #888a8f; + a { + color: #333438; + } + } + + @include S(padding, 15px); + + > .boxLink { + display: grid; + align-items: center; + grid-template-columns: 1fr auto; + + justify-content: center; + background: #fdfdfd uiResource("icons/link.png") top D(3px) right D(3px) / D(9px) no-repeat; + @include S(padding, 5px); + @include S(padding-left, 10px); + @include S(border-radius, $globalBorderRadius); + @include SuperSmallText(); + + font-weight: bold; + box-sizing: border-box; + text-transform: uppercase; + color: #616266; + + transition: background-color 0.12s ease-in-out; + pointer-events: all; + @include S(width, 120px); + @include S(height, 60px); + + cursor: pointer; + &:hover { + background-color: #f0f6ff; + } + + .thirdpartyLogo { + display: inline-block; + @include S(width, 50px); + @include S(height, 50px); + background: center center / 80% no-repeat; + &.githubLogo { + background-image: uiResource("main_menu/github.png"); + } + &.discordLogo { + background-image: uiResource("main_menu/discord.png"); + background-size: 95%; + } + } + } + + > .sidelinks { + display: grid; + align-items: flex-start; + justify-content: flex-start; + grid-template-rows: 1fr 1fr 1fr; + @include S(grid-gap, 3px); + @include S(height, 60px); + + > a { + color: #616266; + background: #fdfdfd; + height: 100%; + + &:hover { + background-color: #f0f6ff; + } + @include SuperSmallText; + text-transform: uppercase; + width: 100%; + @include S(padding, 2px, 10px); + display: flex; + align-items: center; + justify-content: flex-start; + + @include S(padding-left, 25px); + box-sizing: border-box; + font-weight: bold; + background-position: #{D(5px)} center; + background-size: #{D(12px)}; + background-repeat: no-repeat; + @include S(border-radius, $globalBorderRadius); + + transition: background-color 0.12s ease-in-out; + + &.redditLink { + background-image: uiResource("main_menu/reddit.svg"); + } + &.changelog { + background-image: uiResource("main_menu/changelog.svg"); + } + &.helpTranslate { + background-image: uiResource("main_menu/translate.svg"); + } + } + } + } + + @include DarkThemeOverride { + background: $darkModeGameBackground center center / cover !important; + + .topButtons { + filter: invert(1); + + .languageChoose { + filter: invert(1); + } + } + + .mainContainer { + background: darken($darkModeGameBackground, 10); + + .savegames .savegame { + background: darken($darkModeGameBackground, 15); + color: white; + } + } + + .footer { + > a, + .sidelinks > a { + background-color: darken($darkModeGameBackground, 10); + color: #eee; + + &:hover { + background-color: darken($darkModeGameBackground, 8); + } + } + + .author { + color: #bdbdbd; + + > a { + color: white; + } + } + + .thirdpartyLogo.githubLogo { + filter: invert(1); + } + } + } +} diff --git a/src/js/savegame/savegame_manager.js b/src/js/savegame/savegame_manager.js index 42e56734..52f9dc14 100644 --- a/src/js/savegame/savegame_manager.js +++ b/src/js/savegame/savegame_manager.js @@ -1,218 +1,225 @@ -import { ExplainedResult } from "../core/explained_result"; -import { createLogger } from "../core/logging"; -import { ReadWriteProxy } from "../core/read_write_proxy"; -import { globalConfig } from "../core/config"; -import { Savegame } from "./savegame"; -const logger = createLogger("savegame_manager"); - -const Rusha = require("rusha"); - -/** - * @typedef {import("./savegame_typedefs").SavegamesData} SavegamesData - * @typedef {import("./savegame_typedefs").SavegameMetadata} SavegameMetadata - */ - -/** @enum {string} */ -export const enumLocalSavegameStatus = { - offline: "offline", - synced: "synced", -}; - -export class SavegameManager extends ReadWriteProxy { - constructor(app) { - super(app, "savegames.bin"); - - this.currentData = this.getDefaultData(); - } - - // RW Proxy Impl - /** - * @returns {SavegamesData} - */ - getDefaultData() { - return { - version: this.getCurrentVersion(), - savegames: [], - }; - } - - getCurrentVersion() { - return 1001; - } - - /** - * @returns {SavegamesData} - */ - getCurrentData() { - return super.getCurrentData(); - } - - verify(data) { - // TODO / FIXME!!!! - return ExplainedResult.good(); - } - - /** - * - * @param {SavegamesData} data - */ - migrate(data) { - if (data.version < 1001) { - data.savegames.forEach(savegame => { - savegame.level = 0; - }); - data.version = 1001; - } - - return ExplainedResult.good(); - } - - // End rw proxy - - /** - * @returns {Array} - */ - getSavegamesMetaData() { - return this.currentData.savegames; - } - - /** - * - * @param {string} internalId - * @returns {Savegame} - */ - getSavegameById(internalId) { - const metadata = this.getGameMetaDataByInternalId(internalId); - if (!metadata) { - return null; - } - return new Savegame(this.app, { internalId, metaDataRef: metadata }); - } - - /** - * Deletes a savegame - * @param {SavegameMetadata} game - */ - deleteSavegame(game) { - const handle = new Savegame(this.app, { - internalId: game.internalId, - metaDataRef: game, - }); - - return handle.deleteAsync().then(() => { - for (let i = 0; i < this.currentData.savegames.length; ++i) { - const potentialGame = this.currentData.savegames[i]; - if (potentialGame.internalId === handle.internalId) { - this.currentData.savegames.splice(i, 1); - break; - } - } - - return this.writeAsync(); - }); - } - - /** - * Returns a given games metadata by id - * @param {string} id - * @returns {SavegameMetadata} - */ - getGameMetaDataByInternalId(id) { - for (let i = 0; i < this.currentData.savegames.length; ++i) { - const data = this.currentData.savegames[i]; - if (data.internalId === id) { - return data; - } - } - logger.error("Savegame internal id not found:", id); - return null; - } - - /** - * Creates a new savegame - * @returns {Savegame} - */ - createNewSavegame() { - const id = this.generateInternalId(); - - const metaData = /** @type {SavegameMetadata} */ ({ - lastUpdate: Date.now(), - version: Savegame.getCurrentVersion(), - internalId: id, - }); - - this.currentData.savegames.push(metaData); - this.sortSavegames(); - - return new Savegame(this.app, { - internalId: id, - metaDataRef: metaData, - }); - } - - 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} - */ - sortSavegames() { - this.currentData.savegames.sort((a, b) => b.lastUpdate - a.lastUpdate); - let promiseChain = Promise.resolve(); - while (this.currentData.savegames.length > 30) { - const toRemove = this.currentData.savegames.pop(); - - // Try to remove the savegame since its no longer available - const game = new Savegame(this.app, { - internalId: toRemove.internalId, - metaDataRef: toRemove, - }); - promiseChain = promiseChain - .then(() => game.deleteAsync()) - .then( - () => {}, - err => { - logger.error(this, "Failed to remove old savegame:", toRemove, ":", err); - } - ); - } - - return promiseChain; - } - - /** - * Helper method to generate a new internal savegame id - */ - generateInternalId() { - return Rusha.createHash() - .update(Date.now() + "/" + Math.random()) - .digest("hex"); - } - - // End - - initialize() { - // First read, then directly write to ensure we have the latest data - // @ts-ignore - return this.readAsync().then(() => { - if (G_IS_DEV && globalConfig.debug.disableSavegameWrite) { - return Promise.resolve(); - } - return this.sortSavegames().then(() => this.writeAsync()); - }); - } -} +import { ExplainedResult } from "../core/explained_result"; +import { createLogger } from "../core/logging"; +import { ReadWriteProxy } from "../core/read_write_proxy"; +import { globalConfig } from "../core/config"; +import { Savegame } from "./savegame"; +const logger = createLogger("savegame_manager"); + +const Rusha = require("rusha"); + +/** + * @typedef {import("./savegame_typedefs").SavegamesData} SavegamesData + * @typedef {import("./savegame_typedefs").SavegameMetadata} SavegameMetadata + */ + +/** @enum {string} */ +export const enumLocalSavegameStatus = { + offline: "offline", + synced: "synced", +}; + +export class SavegameManager extends ReadWriteProxy { + constructor(app) { + super(app, "savegames.bin"); + + this.currentData = this.getDefaultData(); + } + + // RW Proxy Impl + /** + * @returns {SavegamesData} + */ + getDefaultData() { + return { + version: this.getCurrentVersion(), + savegames: [], + }; + } + + getCurrentVersion() { + return 1002; + } + + /** + * @returns {SavegamesData} + */ + getCurrentData() { + return super.getCurrentData(); + } + + verify(data) { + // TODO / FIXME!!!! + return ExplainedResult.good(); + } + + /** + * + * @param {SavegamesData} data + */ + migrate(data) { + if (data.version < 1001) { + data.savegames.forEach(savegame => { + savegame.level = 0; + }); + data.version = 1001; + } + + if (data.version < 1002) { + data.savegames.forEach(savegame => { + savegame.name = null; + }); + data.version = 1002; + } + + return ExplainedResult.good(); + } + + // End rw proxy + + /** + * @returns {Array} + */ + getSavegamesMetaData() { + return this.currentData.savegames; + } + + /** + * + * @param {string} internalId + * @returns {Savegame} + */ + getSavegameById(internalId) { + const metadata = this.getGameMetaDataByInternalId(internalId); + if (!metadata) { + return null; + } + return new Savegame(this.app, { internalId, metaDataRef: metadata }); + } + + /** + * Deletes a savegame + * @param {SavegameMetadata} game + */ + deleteSavegame(game) { + const handle = new Savegame(this.app, { + internalId: game.internalId, + metaDataRef: game, + }); + + return handle.deleteAsync().then(() => { + for (let i = 0; i < this.currentData.savegames.length; ++i) { + const potentialGame = this.currentData.savegames[i]; + if (potentialGame.internalId === handle.internalId) { + this.currentData.savegames.splice(i, 1); + break; + } + } + + return this.writeAsync(); + }); + } + + /** + * Returns a given games metadata by id + * @param {string} id + * @returns {SavegameMetadata} + */ + getGameMetaDataByInternalId(id) { + for (let i = 0; i < this.currentData.savegames.length; ++i) { + const data = this.currentData.savegames[i]; + if (data.internalId === id) { + return data; + } + } + logger.error("Savegame internal id not found:", id); + return null; + } + + /** + * Creates a new savegame + * @returns {Savegame} + */ + createNewSavegame() { + const id = this.generateInternalId(); + + const metaData = /** @type {SavegameMetadata} */ ({ + lastUpdate: Date.now(), + version: Savegame.getCurrentVersion(), + internalId: id, + }); + + this.currentData.savegames.push(metaData); + this.sortSavegames(); + + return new Savegame(this.app, { + internalId: id, + metaDataRef: metaData, + }); + } + + 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} + */ + sortSavegames() { + this.currentData.savegames.sort((a, b) => b.lastUpdate - a.lastUpdate); + let promiseChain = Promise.resolve(); + while (this.currentData.savegames.length > 30) { + const toRemove = this.currentData.savegames.pop(); + + // Try to remove the savegame since its no longer available + const game = new Savegame(this.app, { + internalId: toRemove.internalId, + metaDataRef: toRemove, + }); + promiseChain = promiseChain + .then(() => game.deleteAsync()) + .then( + () => {}, + err => { + logger.error(this, "Failed to remove old savegame:", toRemove, ":", err); + } + ); + } + + return promiseChain; + } + + /** + * Helper method to generate a new internal savegame id + */ + generateInternalId() { + return Rusha.createHash() + .update(Date.now() + "/" + Math.random()) + .digest("hex"); + } + + // End + + initialize() { + // First read, then directly write to ensure we have the latest data + // @ts-ignore + return this.readAsync().then(() => { + if (G_IS_DEV && globalConfig.debug.disableSavegameWrite) { + return Promise.resolve(); + } + return this.sortSavegames().then(() => this.writeAsync()); + }); + } +} diff --git a/src/js/savegame/savegame_typedefs.js b/src/js/savegame/savegame_typedefs.js index f5bb08c2..0f94cd6a 100644 --- a/src/js/savegame/savegame_typedefs.js +++ b/src/js/savegame/savegame_typedefs.js @@ -1,38 +1,39 @@ -/** - * @typedef {import("../game/entity").Entity} Entity - * - * @typedef {{}} SavegameStats - * - * @typedef {{ - * camera: any, - * time: any, - * entityMgr: any, - * map: any, - * hubGoals: any, - * pinnedShapes: any, - * waypoints: any, - * entities: Array, - * beltPaths: Array - * }} SerializedGame - * - * @typedef {{ - * version: number, - * dump: SerializedGame, - * stats: SavegameStats, - * lastUpdate: number, - * }} SavegameData - * - * @typedef {{ - * lastUpdate: number, - * version: number, - * internalId: string, - * level: number - * }} SavegameMetadata - * - * @typedef {{ - * version: number, - * savegames: Array - * }} SavegamesData - */ - -export default {}; +/** + * @typedef {import("../game/entity").Entity} Entity + * + * @typedef {{}} SavegameStats + * + * @typedef {{ + * camera: any, + * time: any, + * entityMgr: any, + * map: any, + * hubGoals: any, + * pinnedShapes: any, + * waypoints: any, + * entities: Array, + * beltPaths: Array + * }} SerializedGame + * + * @typedef {{ + * version: number, + * dump: SerializedGame, + * stats: SavegameStats, + * lastUpdate: number, + * }} SavegameData + * + * @typedef {{ + * lastUpdate: number, + * version: number, + * internalId: string, + * level: number + * name: string|null + * }} SavegameMetadata + * + * @typedef {{ + * version: number, + * savegames: Array + * }} SavegamesData + */ + +export default {}; diff --git a/src/js/states/main_menu.js b/src/js/states/main_menu.js index 3d39e826..3dbf648a 100644 --- a/src/js/states/main_menu.js +++ b/src/js/states/main_menu.js @@ -1,534 +1,579 @@ -import { GameState } from "../core/game_state"; -import { cachebust } from "../core/cachebust"; -import { globalConfig, IS_DEMO, THIRDPARTY_URLS } from "../core/config"; -import { - makeDiv, - makeButtonElement, - formatSecondsToTimeAgo, - waitNextFrame, - isSupportedBrowser, - makeButton, - removeAllChildren, -} from "../core/utils"; -import { ReadWriteProxy } from "../core/read_write_proxy"; -import { HUDModalDialogs } from "../game/hud/parts/modal_dialogs"; -import { T } from "../translations"; -import { getApplicationSettingById } from "../profile/application_settings"; - -/** - * @typedef {import("../savegame/savegame_typedefs").SavegameMetadata} SavegameMetadata - * @typedef {import("../profile/setting_types").EnumSetting} EnumSetting - */ - -/** - * Generates a file download - * @param {string} filename - * @param {string} text - */ -function generateFileDownload(filename, text) { - var element = document.createElement("a"); - element.setAttribute("href", "data:text/plain;charset=utf-8," + encodeURIComponent(text)); - element.setAttribute("download", filename); - - element.style.display = "none"; - document.body.appendChild(element); - - element.click(); - document.body.removeChild(element); -} - -export class MainMenuState extends GameState { - constructor() { - super("MainMenuState"); - } - - getInnerHTML() { - const bannerHtml = ` -

${T.demoBanners.title}

- -

${T.demoBanners.intro}

- - Get the shapez.io standalone! - `; - - return ` - -
- - - - ${ - G_IS_STANDALONE || G_IS_DEV - ? ` - - ` - : "" - } -
- - - - - - - -
- -
- ${IS_DEMO ? `
${bannerHtml}
` : ""} -
- -
- ${ - isSupportedBrowser() - ? "" - : `
${T.mainMenu.browserWarning}
` - } -
-
- - -
- - - `; - } - - requestImportSavegame() { - if ( - IS_DEMO && - this.app.savegameMgr.getSavegamesMetaData().length > 0 && - !this.app.platformWrapper.getHasUnlimitedSavegames() - ) { - this.app.analytics.trackUiClick("importgame_slot_limit_show"); - this.dialogs.showWarning(T.dialogs.oneSavegameLimit.title, T.dialogs.oneSavegameLimit.desc); - return; - } - - var input = document.createElement("input"); - input.type = "file"; - input.accept = ".bin"; - - input.onchange = e => { - const file = input.files[0]; - if (file) { - waitNextFrame().then(() => { - this.app.analytics.trackUiClick("import_savegame"); - const closeLoader = this.dialogs.showLoadingDialog(); - const reader = new FileReader(); - reader.addEventListener("load", event => { - const contents = event.target.result; - let realContent; - - try { - realContent = ReadWriteProxy.deserializeObject(contents); - } catch (err) { - closeLoader(); - this.dialogs.showWarning( - T.dialogs.importSavegameError.title, - T.dialogs.importSavegameError.text + "

" + err - ); - return; - } - - this.app.savegameMgr.importSavegame(realContent).then( - () => { - closeLoader(); - this.dialogs.showWarning( - T.dialogs.importSavegameSuccess.title, - T.dialogs.importSavegameSuccess.text - ); - - this.renderMainMenu(); - this.renderSavegames(); - }, - err => { - closeLoader(); - this.dialogs.showWarning( - T.dialogs.importSavegameError.title, - T.dialogs.importSavegameError.text + ":

" + err - ); - } - ); - }); - reader.addEventListener("error", error => { - this.dialogs.showWarning( - T.dialogs.importSavegameError.title, - T.dialogs.importSavegameError.text + ":

" + error - ); - }); - reader.readAsText(file, "utf-8"); - }); - } - }; - input.click(); - } - - onBackButton() { - this.app.platformWrapper.exitApp(); - } - - onEnter(payload) { - this.dialogs = new HUDModalDialogs(null, this.app); - const dialogsElement = document.body.querySelector(".modalDialogParent"); - this.dialogs.initializeToElement(dialogsElement); - - if (payload.loadError) { - this.dialogs.showWarning( - T.dialogs.gameLoadFailure.title, - T.dialogs.gameLoadFailure.text + "

" + payload.loadError - ); - } - - const qs = this.htmlElement.querySelector.bind(this.htmlElement); - - if (G_IS_DEV && globalConfig.debug.fastGameEnter) { - const games = this.app.savegameMgr.getSavegamesMetaData(); - if (games.length > 0 && globalConfig.debug.resumeGameOnFastEnter) { - this.resumeGame(games[0]); - } else { - this.onPlayButtonClicked(); - } - } - - // Initialize video - this.videoElement = this.htmlElement.querySelector("video"); - this.videoElement.playbackRate = 0.9; - this.videoElement.addEventListener("canplay", () => { - if (this.videoElement) { - this.videoElement.classList.add("loaded"); - } - }); - - this.trackClicks(qs(".settingsButton"), this.onSettingsButtonClicked); - this.trackClicks(qs(".changelog"), this.onChangelogClicked); - this.trackClicks(qs(".redditLink"), this.onRedditClicked); - this.trackClicks(qs(".languageChoose"), this.onLanguageChooseClicked); - this.trackClicks(qs(".helpTranslate"), this.onTranslationHelpLinkClicked); - - if (G_IS_STANDALONE) { - this.trackClicks(qs(".exitAppButton"), this.onExitAppButtonClicked); - } - - this.renderMainMenu(); - this.renderSavegames(); - - const steamLink = this.htmlElement.querySelector(".steamLink"); - if (steamLink) { - this.trackClicks(steamLink, () => this.onSteamLinkClicked(), { preventClick: true }); - } - - const discordLink = this.htmlElement.querySelector(".discordLink"); - this.trackClicks( - discordLink, - () => this.app.platformWrapper.openExternalLink(THIRDPARTY_URLS.discord), - { preventClick: true } - ); - - const githubLink = this.htmlElement.querySelector(".githubLink"); - this.trackClicks( - githubLink, - () => this.app.platformWrapper.openExternalLink(THIRDPARTY_URLS.github), - { preventClick: true } - ); - - const producerLink = this.htmlElement.querySelector(".producerLink"); - this.trackClicks( - producerLink, - () => this.app.platformWrapper.openExternalLink("https://tobspr.com"), - { preventClick: true } - ); - } - - renderMainMenu() { - const buttonContainer = this.htmlElement.querySelector(".mainContainer .buttons"); - removeAllChildren(buttonContainer); - - // Import button - const importButtonElement = makeButtonElement( - ["importButton", "styledButton"], - T.mainMenu.importSavegame - ); - this.trackClicks(importButtonElement, this.requestImportSavegame); - - if (this.savedGames.length > 0) { - // Continue game - const continueButton = makeButton( - buttonContainer, - ["continueButton", "styledButton"], - T.mainMenu.continue - ); - this.trackClicks(continueButton, this.onContinueButtonClicked); - - const outerDiv = makeDiv(buttonContainer, null, ["outer"], null); - outerDiv.appendChild(importButtonElement); - const newGameButton = makeButton( - this.htmlElement.querySelector(".mainContainer .outer"), - ["newGameButton", "styledButton"], - T.mainMenu.newGame - ); - this.trackClicks(newGameButton, this.onPlayButtonClicked); - } else { - // New game - const playBtn = makeButton(buttonContainer, ["playButton", "styledButton"], T.mainMenu.play); - this.trackClicks(playBtn, this.onPlayButtonClicked); - buttonContainer.appendChild(importButtonElement); - } - } - - onSteamLinkClicked() { - this.app.analytics.trackUiClick("main_menu_steam_link_2"); - this.app.platformWrapper.openExternalLink(THIRDPARTY_URLS.standaloneStorePage); - return false; - } - - onExitAppButtonClicked() { - this.app.platformWrapper.exitApp(); - } - - onChangelogClicked() { - this.moveToState("ChangelogState"); - } - - onRedditClicked() { - this.app.analytics.trackUiClick("main_menu_reddit_link"); - this.app.platformWrapper.openExternalLink(THIRDPARTY_URLS.reddit); - } - - onLanguageChooseClicked() { - this.app.analytics.trackUiClick("choose_language"); - const setting = /** @type {EnumSetting} */ (getApplicationSettingById("language")); - - const { optionSelected } = this.dialogs.showOptionChooser(T.settings.labels.language.title, { - active: this.app.settings.getLanguage(), - options: setting.options.map(option => ({ - value: setting.valueGetter(option), - text: setting.textGetter(option), - desc: setting.descGetter(option), - iconPrefix: setting.iconPrefix, - })), - }); - - optionSelected.add(value => { - this.app.settings.updateLanguage(value); - if (setting.restartRequired) { - if (this.app.platformWrapper.getSupportsRestart()) { - this.app.platformWrapper.performRestart(); - } else { - this.dialogs.showInfo(T.dialogs.restartRequired.title, T.dialogs.restartRequired.text, [ - "ok:good", - ]); - } - } - - if (setting.changeCb) { - setting.changeCb(this.app, value); - } - - // Update current icon - this.htmlElement.querySelector("button.languageChoose").setAttribute("data-languageIcon", value); - }, this); - } - - get savedGames() { - return this.app.savegameMgr.getSavegamesMetaData(); - } - - renderSavegames() { - const oldContainer = this.htmlElement.querySelector(".mainContainer .savegames"); - if (oldContainer) { - oldContainer.remove(); - } - const games = this.savedGames; - if (games.length > 0) { - const parent = makeDiv(this.htmlElement.querySelector(".mainContainer"), null, ["savegames"]); - - for (let i = 0; i < games.length; ++i) { - const elem = makeDiv(parent, null, ["savegame"]); - - makeDiv( - elem, - null, - ["playtime"], - formatSecondsToTimeAgo((new Date().getTime() - games[i].lastUpdate) / 1000.0) - ); - - makeDiv( - elem, - null, - ["level"], - games[i].level - ? T.mainMenu.savegameLevel.replace("", "" + games[i].level) - : T.mainMenu.savegameLevelUnknown - ); - - const deleteButton = document.createElement("button"); - deleteButton.classList.add("styledButton", "deleteGame"); - elem.appendChild(deleteButton); - - const downloadButton = document.createElement("button"); - downloadButton.classList.add("styledButton", "downloadGame"); - elem.appendChild(downloadButton); - - const resumeButton = document.createElement("button"); - resumeButton.classList.add("styledButton", "resumeGame"); - elem.appendChild(resumeButton); - - this.trackClicks(deleteButton, () => this.deleteGame(games[i])); - this.trackClicks(downloadButton, () => this.downloadGame(games[i])); - this.trackClicks(resumeButton, () => this.resumeGame(games[i])); - } - } - } - - /** - * @param {SavegameMetadata} game - */ - resumeGame(game) { - this.app.analytics.trackUiClick("resume_game"); - - this.app.adProvider.showVideoAd().then(() => { - this.app.analytics.trackUiClick("resume_game_adcomplete"); - const savegame = this.app.savegameMgr.getSavegameById(game.internalId); - savegame - .readAsync() - .then(() => { - this.moveToState("InGameState", { - savegame, - }); - }) - .catch(err => { - this.dialogs.showWarning( - T.dialogs.gameLoadFailure.title, - T.dialogs.gameLoadFailure.text + "

" + err - ); - }); - }); - } - - /** - * @param {SavegameMetadata} game - */ - deleteGame(game) { - this.app.analytics.trackUiClick("delete_game"); - - const signals = this.dialogs.showWarning( - T.dialogs.confirmSavegameDelete.title, - T.dialogs.confirmSavegameDelete.text, - ["delete:bad", "cancel:good"] - ); - - signals.delete.add(() => { - this.app.savegameMgr.deleteSavegame(game).then( - () => { - this.renderSavegames(); - if (this.savedGames.length <= 0) this.renderMainMenu(); - }, - err => { - this.dialogs.showWarning( - T.dialogs.savegameDeletionError.title, - T.dialogs.savegameDeletionError.text + "

" + err - ); - } - ); - }); - } - - /** - * @param {SavegameMetadata} game - */ - downloadGame(game) { - this.app.analytics.trackUiClick("download_game"); - - const savegame = this.app.savegameMgr.getSavegameById(game.internalId); - savegame.readAsync().then(() => { - const data = ReadWriteProxy.serializeObject(savegame.currentData); - generateFileDownload(savegame.filename, data); - }); - } - - onSettingsButtonClicked() { - this.moveToState("SettingsState"); - } - - onTranslationHelpLinkClicked() { - this.app.analytics.trackUiClick("translation_help_link"); - this.app.platformWrapper.openExternalLink( - "https://github.com/tobspr/shapez.io/blob/master/translations" - ); - } - - onPlayButtonClicked() { - if ( - IS_DEMO && - this.app.savegameMgr.getSavegamesMetaData().length > 0 && - !this.app.platformWrapper.getHasUnlimitedSavegames() - ) { - this.app.analytics.trackUiClick("startgame_slot_limit_show"); - this.dialogs.showWarning(T.dialogs.oneSavegameLimit.title, T.dialogs.oneSavegameLimit.desc); - return; - } - - this.app.analytics.trackUiClick("startgame"); - this.app.adProvider.showVideoAd().then(() => { - const savegame = this.app.savegameMgr.createNewSavegame(); - - this.moveToState("InGameState", { - savegame, - }); - this.app.analytics.trackUiClick("startgame_adcomplete"); - }); - } - - onContinueButtonClicked() { - let latestLastUpdate = 0; - let latestInternalId; - this.app.savegameMgr.currentData.savegames.forEach(saveGame => { - if (saveGame.lastUpdate > latestLastUpdate) { - latestLastUpdate = saveGame.lastUpdate; - latestInternalId = saveGame.internalId; - } - }); - - const savegame = this.app.savegameMgr.getSavegameById(latestInternalId); - savegame.readAsync().then(() => { - this.moveToState("InGameState", { - savegame, - }); - }); - } - - onLeave() { - this.dialogs.cleanup(); - } -} +import { GameState } from "../core/game_state"; +import { cachebust } from "../core/cachebust"; +import { globalConfig, IS_DEMO, THIRDPARTY_URLS } from "../core/config"; +import { + makeDiv, + makeButtonElement, + formatSecondsToTimeAgo, + waitNextFrame, + isSupportedBrowser, + makeButton, + removeAllChildren, +} from "../core/utils"; +import { ReadWriteProxy } from "../core/read_write_proxy"; +import { HUDModalDialogs } from "../game/hud/parts/modal_dialogs"; +import { T } from "../translations"; +import { getApplicationSettingById } from "../profile/application_settings"; +import { FormElementInput } from "../core/modal_dialog_forms"; +import { DialogWithForm } from "../core/modal_dialog_elements"; + +/** + * @typedef {import("../savegame/savegame_typedefs").SavegameMetadata} SavegameMetadata + * @typedef {import("../profile/setting_types").EnumSetting} EnumSetting + */ + +/** + * Generates a file download + * @param {string} filename + * @param {string} text + */ +function generateFileDownload(filename, text) { + var element = document.createElement("a"); + element.setAttribute("href", "data:text/plain;charset=utf-8," + encodeURIComponent(text)); + element.setAttribute("download", filename); + + element.style.display = "none"; + document.body.appendChild(element); + + element.click(); + document.body.removeChild(element); +} + +export class MainMenuState extends GameState { + constructor() { + super("MainMenuState"); + } + + getInnerHTML() { + const bannerHtml = ` +

${T.demoBanners.title}

+ +

${T.demoBanners.intro}

+ + Get the shapez.io standalone! + `; + + return ` + +
+ + + + ${ + G_IS_STANDALONE || G_IS_DEV + ? ` + + ` + : "" + } +
+ + + + + + + +
+ +
+ ${IS_DEMO ? `
${bannerHtml}
` : ""} +
+ +
+ ${ + isSupportedBrowser() + ? "" + : `
${T.mainMenu.browserWarning}
` + } +
+
+ + +
+ + + `; + } + + requestImportSavegame() { + if ( + IS_DEMO && + this.app.savegameMgr.getSavegamesMetaData().length > 0 && + !this.app.platformWrapper.getHasUnlimitedSavegames() + ) { + this.app.analytics.trackUiClick("importgame_slot_limit_show"); + this.dialogs.showWarning(T.dialogs.oneSavegameLimit.title, T.dialogs.oneSavegameLimit.desc); + return; + } + + var input = document.createElement("input"); + input.type = "file"; + input.accept = ".bin"; + + input.onchange = e => { + const file = input.files[0]; + if (file) { + waitNextFrame().then(() => { + this.app.analytics.trackUiClick("import_savegame"); + const closeLoader = this.dialogs.showLoadingDialog(); + const reader = new FileReader(); + reader.addEventListener("load", event => { + const contents = event.target.result; + let realContent; + + try { + realContent = ReadWriteProxy.deserializeObject(contents); + } catch (err) { + closeLoader(); + this.dialogs.showWarning( + T.dialogs.importSavegameError.title, + T.dialogs.importSavegameError.text + "

" + err + ); + return; + } + + this.app.savegameMgr.importSavegame(realContent).then( + () => { + closeLoader(); + this.dialogs.showWarning( + T.dialogs.importSavegameSuccess.title, + T.dialogs.importSavegameSuccess.text + ); + + this.renderMainMenu(); + this.renderSavegames(); + }, + err => { + closeLoader(); + this.dialogs.showWarning( + T.dialogs.importSavegameError.title, + T.dialogs.importSavegameError.text + ":

" + err + ); + } + ); + }); + reader.addEventListener("error", error => { + this.dialogs.showWarning( + T.dialogs.importSavegameError.title, + T.dialogs.importSavegameError.text + ":

" + error + ); + }); + reader.readAsText(file, "utf-8"); + }); + } + }; + input.click(); + } + + onBackButton() { + this.app.platformWrapper.exitApp(); + } + + onEnter(payload) { + this.dialogs = new HUDModalDialogs(null, this.app); + const dialogsElement = document.body.querySelector(".modalDialogParent"); + this.dialogs.initializeToElement(dialogsElement); + + if (payload.loadError) { + this.dialogs.showWarning( + T.dialogs.gameLoadFailure.title, + T.dialogs.gameLoadFailure.text + "

" + payload.loadError + ); + } + + const qs = this.htmlElement.querySelector.bind(this.htmlElement); + + if (G_IS_DEV && globalConfig.debug.fastGameEnter) { + const games = this.app.savegameMgr.getSavegamesMetaData(); + if (games.length > 0 && globalConfig.debug.resumeGameOnFastEnter) { + this.resumeGame(games[0]); + } else { + this.onPlayButtonClicked(); + } + } + + // Initialize video + this.videoElement = this.htmlElement.querySelector("video"); + this.videoElement.playbackRate = 0.9; + this.videoElement.addEventListener("canplay", () => { + if (this.videoElement) { + this.videoElement.classList.add("loaded"); + } + }); + + this.trackClicks(qs(".settingsButton"), this.onSettingsButtonClicked); + this.trackClicks(qs(".changelog"), this.onChangelogClicked); + this.trackClicks(qs(".redditLink"), this.onRedditClicked); + this.trackClicks(qs(".languageChoose"), this.onLanguageChooseClicked); + this.trackClicks(qs(".helpTranslate"), this.onTranslationHelpLinkClicked); + + if (G_IS_STANDALONE) { + this.trackClicks(qs(".exitAppButton"), this.onExitAppButtonClicked); + } + + this.renderMainMenu(); + this.renderSavegames(); + + const steamLink = this.htmlElement.querySelector(".steamLink"); + if (steamLink) { + this.trackClicks(steamLink, () => this.onSteamLinkClicked(), { preventClick: true }); + } + + const discordLink = this.htmlElement.querySelector(".discordLink"); + this.trackClicks( + discordLink, + () => this.app.platformWrapper.openExternalLink(THIRDPARTY_URLS.discord), + { preventClick: true } + ); + + const githubLink = this.htmlElement.querySelector(".githubLink"); + this.trackClicks( + githubLink, + () => this.app.platformWrapper.openExternalLink(THIRDPARTY_URLS.github), + { preventClick: true } + ); + + const producerLink = this.htmlElement.querySelector(".producerLink"); + this.trackClicks( + producerLink, + () => this.app.platformWrapper.openExternalLink("https://tobspr.com"), + { preventClick: true } + ); + } + + renderMainMenu() { + const buttonContainer = this.htmlElement.querySelector(".mainContainer .buttons"); + removeAllChildren(buttonContainer); + + // Import button + const importButtonElement = makeButtonElement( + ["importButton", "styledButton"], + T.mainMenu.importSavegame + ); + this.trackClicks(importButtonElement, this.requestImportSavegame); + + if (this.savedGames.length > 0) { + // Continue game + const continueButton = makeButton( + buttonContainer, + ["continueButton", "styledButton"], + T.mainMenu.continue + ); + this.trackClicks(continueButton, this.onContinueButtonClicked); + + const outerDiv = makeDiv(buttonContainer, null, ["outer"], null); + outerDiv.appendChild(importButtonElement); + const newGameButton = makeButton( + this.htmlElement.querySelector(".mainContainer .outer"), + ["newGameButton", "styledButton"], + T.mainMenu.newGame + ); + this.trackClicks(newGameButton, this.onPlayButtonClicked); + } else { + // New game + const playBtn = makeButton(buttonContainer, ["playButton", "styledButton"], T.mainMenu.play); + this.trackClicks(playBtn, this.onPlayButtonClicked); + buttonContainer.appendChild(importButtonElement); + } + } + + onSteamLinkClicked() { + this.app.analytics.trackUiClick("main_menu_steam_link_2"); + this.app.platformWrapper.openExternalLink(THIRDPARTY_URLS.standaloneStorePage); + return false; + } + + onExitAppButtonClicked() { + this.app.platformWrapper.exitApp(); + } + + onChangelogClicked() { + this.moveToState("ChangelogState"); + } + + onRedditClicked() { + this.app.analytics.trackUiClick("main_menu_reddit_link"); + this.app.platformWrapper.openExternalLink(THIRDPARTY_URLS.reddit); + } + + onLanguageChooseClicked() { + this.app.analytics.trackUiClick("choose_language"); + const setting = /** @type {EnumSetting} */ (getApplicationSettingById("language")); + + const { optionSelected } = this.dialogs.showOptionChooser(T.settings.labels.language.title, { + active: this.app.settings.getLanguage(), + options: setting.options.map(option => ({ + value: setting.valueGetter(option), + text: setting.textGetter(option), + desc: setting.descGetter(option), + iconPrefix: setting.iconPrefix, + })), + }); + + optionSelected.add(value => { + this.app.settings.updateLanguage(value); + if (setting.restartRequired) { + if (this.app.platformWrapper.getSupportsRestart()) { + this.app.platformWrapper.performRestart(); + } else { + this.dialogs.showInfo(T.dialogs.restartRequired.title, T.dialogs.restartRequired.text, [ + "ok:good", + ]); + } + } + + if (setting.changeCb) { + setting.changeCb(this.app, value); + } + + // Update current icon + this.htmlElement.querySelector("button.languageChoose").setAttribute("data-languageIcon", value); + }, this); + } + + get savedGames() { + return this.app.savegameMgr.getSavegamesMetaData(); + } + + renderSavegames() { + const oldContainer = this.htmlElement.querySelector(".mainContainer .savegames"); + if (oldContainer) { + oldContainer.remove(); + } + const games = this.savedGames; + if (games.length > 0) { + const parent = makeDiv(this.htmlElement.querySelector(".mainContainer"), null, ["savegames"]); + + for (let i = 0; i < games.length; ++i) { + const elem = makeDiv(parent, null, ["savegame"]); + + makeDiv( + elem, + null, + ["playtime"], + formatSecondsToTimeAgo((new Date().getTime() - games[i].lastUpdate) / 1000.0) + ); + + makeDiv( + elem, + null, + ["level"], + games[i].level + ? T.mainMenu.savegameLevel.replace("", "" + games[i].level) + : T.mainMenu.savegameLevelUnknown + ); + + const name = makeDiv( + elem, + null, + ["name"], + games[i].name ? games[i].name : T.mainMenu.savegameUnnamed + ); + + const deleteButton = document.createElement("button"); + deleteButton.classList.add("styledButton", "deleteGame"); + elem.appendChild(deleteButton); + + const downloadButton = document.createElement("button"); + downloadButton.classList.add("styledButton", "downloadGame"); + elem.appendChild(downloadButton); + + const renameButton = document.createElement("button"); + renameButton.classList.add("styledButton", "renameGame"); + name.appendChild(renameButton); + + const resumeButton = document.createElement("button"); + resumeButton.classList.add("styledButton", "resumeGame"); + elem.appendChild(resumeButton); + + this.trackClicks(deleteButton, () => this.deleteGame(games[i])); + this.trackClicks(downloadButton, () => this.downloadGame(games[i])); + this.trackClicks(resumeButton, () => this.resumeGame(games[i])); + this.trackClicks(renameButton, () => this.requestRenameSavegame(games[i])); + } + } + } + + /** + * @param {SavegameMetadata} game + */ + requestRenameSavegame(game) { + const regex = /^[a-zA-Z0-9_\- ]{1,20}$/; + + const nameInput = new FormElementInput({ + id: "nameInput", + label: null, + placeholder: "", + defaultValue: game.name || "", + validator: val => val.match(regex), + }); + const dialog = new DialogWithForm({ + app: this.app, + title: T.dialogs.renameSavegame.title, + desc: T.dialogs.renameSavegame.desc, + formElements: [nameInput], + buttons: ["cancel:bad:escape", "ok:good:enter"], + }); + this.dialogs.internalShowDialog(dialog); + + // When confirmed, save the name + dialog.buttonSignals.ok.add(() => { + game.name = nameInput.getValue(); + this.app.savegameMgr.writeAsync(); + this.renderSavegames(); + }); + } + + /** + * @param {SavegameMetadata} game + */ + resumeGame(game) { + this.app.analytics.trackUiClick("resume_game"); + + this.app.adProvider.showVideoAd().then(() => { + this.app.analytics.trackUiClick("resume_game_adcomplete"); + const savegame = this.app.savegameMgr.getSavegameById(game.internalId); + savegame + .readAsync() + .then(() => { + this.moveToState("InGameState", { + savegame, + }); + }) + .catch(err => { + this.dialogs.showWarning( + T.dialogs.gameLoadFailure.title, + T.dialogs.gameLoadFailure.text + "

" + err + ); + }); + }); + } + + /** + * @param {SavegameMetadata} game + */ + deleteGame(game) { + this.app.analytics.trackUiClick("delete_game"); + + const signals = this.dialogs.showWarning( + T.dialogs.confirmSavegameDelete.title, + T.dialogs.confirmSavegameDelete.text, + ["delete:bad", "cancel:good"] + ); + + signals.delete.add(() => { + this.app.savegameMgr.deleteSavegame(game).then( + () => { + this.renderSavegames(); + if (this.savedGames.length <= 0) this.renderMainMenu(); + }, + err => { + this.dialogs.showWarning( + T.dialogs.savegameDeletionError.title, + T.dialogs.savegameDeletionError.text + "

" + err + ); + } + ); + }); + } + + /** + * @param {SavegameMetadata} game + */ + downloadGame(game) { + this.app.analytics.trackUiClick("download_game"); + + const savegame = this.app.savegameMgr.getSavegameById(game.internalId); + savegame.readAsync().then(() => { + const data = ReadWriteProxy.serializeObject(savegame.currentData); + const filename = (game.name || "unnamed") + ".bin"; + generateFileDownload(filename, data); + }); + } + + onSettingsButtonClicked() { + this.moveToState("SettingsState"); + } + + onTranslationHelpLinkClicked() { + this.app.analytics.trackUiClick("translation_help_link"); + this.app.platformWrapper.openExternalLink( + "https://github.com/tobspr/shapez.io/blob/master/translations" + ); + } + + onPlayButtonClicked() { + if ( + IS_DEMO && + this.app.savegameMgr.getSavegamesMetaData().length > 0 && + !this.app.platformWrapper.getHasUnlimitedSavegames() + ) { + this.app.analytics.trackUiClick("startgame_slot_limit_show"); + this.dialogs.showWarning(T.dialogs.oneSavegameLimit.title, T.dialogs.oneSavegameLimit.desc); + return; + } + + this.app.analytics.trackUiClick("startgame"); + this.app.adProvider.showVideoAd().then(() => { + const savegame = this.app.savegameMgr.createNewSavegame(); + + this.moveToState("InGameState", { + savegame, + }); + this.app.analytics.trackUiClick("startgame_adcomplete"); + }); + } + + onContinueButtonClicked() { + let latestLastUpdate = 0; + let latestInternalId; + this.app.savegameMgr.currentData.savegames.forEach(saveGame => { + if (saveGame.lastUpdate > latestLastUpdate) { + latestLastUpdate = saveGame.lastUpdate; + latestInternalId = saveGame.internalId; + } + }); + + const savegame = this.app.savegameMgr.getSavegameById(latestInternalId); + savegame.readAsync().then(() => { + this.moveToState("InGameState", { + savegame, + }); + }); + } + + onLeave() { + this.dialogs.cleanup(); + } +} diff --git a/translations/base-en.yaml b/translations/base-en.yaml index e7407179..91c5e749 100644 --- a/translations/base-en.yaml +++ b/translations/base-en.yaml @@ -157,6 +157,7 @@ mainMenu: savegameLevel: Level savegameLevelUnknown: Unknown Level + savegameUnnamed: Unnamed dialogs: buttons: @@ -274,6 +275,10 @@ dialogs: title: Export screenshot desc: You requested to export your base as a screenshot. Please note that this can be quite slow for a big base and even crash your game! + renameSavegame: + title: Rename Savegame + desc: You can rename your savegame here. + ingame: # This is shown in the top left corner and displays useful keybindings in # every situation