diff --git a/res/ui/icons/enum_selector.png b/res/ui/icons/enum_selector.png new file mode 100644 index 00000000..0d54ca59 Binary files /dev/null and b/res/ui/icons/enum_selector.png differ diff --git a/res/ui/icons/state_back_button.png b/res/ui/icons/state_back_button.png new file mode 100644 index 00000000..78685f5e Binary files /dev/null and b/res/ui/icons/state_back_button.png differ diff --git a/src/css/common.scss b/src/css/common.scss index ee646730..e48658aa 100644 --- a/src/css/common.scss +++ b/src/css/common.scss @@ -519,14 +519,14 @@ canvas { .checkbox { $bgColor: darken($mainBgColor, 0); background-color: $bgColor; - @include S(width, 45px); - @include S(height, 20px); + @include S(width, 35px); + @include S(height, 17px); display: flex; @include S(padding, 3px); box-sizing: content-box; cursor: pointer; pointer-events: all; - transition: opacity 0.2s ease-in-out, background-color 0.4s ease-in-out, box-shadow 0.4s ease-in-out !important; + transition: opacity 0.2s ease-in-out, background-color 0.3s ease-in-out, box-shadow 0.4s ease-in-out !important; position: relative; @include BorderRadius(20px); @include IncreasedClickArea(10px); @@ -535,9 +535,13 @@ canvas { opacity: 0.2; } + &:hover { + background-color: darken($bgColor, 5); + } + .knob { @include S(width, 20px); - @include S(height, 20px); + @include S(height, 17px); display: inline-block; transition: margin-left 0.4s ease-in-out !important; background: #fff; @@ -550,7 +554,11 @@ canvas { background-color: $themeColor; @include BoxShadow3D($themeColor, $size: 2px); .knob { - @include S(margin-left, 25px); + @include S(margin-left, 15px); + } + + &:hover { + background-color: lighten($themeColor, 15); } } } diff --git a/src/css/ingame_hud/dialogs.scss b/src/css/ingame_hud/dialogs.scss index c8ed3315..e2a79ed9 100644 --- a/src/css/ingame_hud/dialogs.scss +++ b/src/css/ingame_hud/dialogs.scss @@ -49,6 +49,20 @@ @include S(padding, 12px); pointer-events: all; + &.optionChooserDialog { + .optionParent { + display: grid; + @include S(grid-gap, 5px); + grid-template-columns: 1fr 1fr; + .option { + pointer-events: all; + cursor: pointer; + @include S(padding, 10px); + background: #eee; + } + } + } + > .title { @include Heading; margin: 0; diff --git a/src/css/main.scss b/src/css/main.scss index 1bb07d65..166eb6f7 100644 --- a/src/css/main.scss +++ b/src/css/main.scss @@ -23,6 +23,7 @@ @import "states/preload"; @import "states/main_menu"; @import "states/ingame"; +@import "states/settings"; @import "ingame_hud/buildings_toolbar"; @import "ingame_hud/building_placer"; diff --git a/src/css/states/settings.scss b/src/css/states/settings.scss new file mode 100644 index 00000000..f4b6e6b4 --- /dev/null +++ b/src/css/states/settings.scss @@ -0,0 +1,57 @@ +#state_SettingsState { + .content { + .categoryLabel { + display: block; + text-transform: uppercase; + @include S(margin-top, 15px); + @include S(margin-bottom, 15px); + @include Heading; + } + + .setting { + @include S(padding, 10px); + background: #eee; + @include S(border-radius, 2px); + @include S(margin-bottom, 5px); + + label { + text-transform: uppercase; + @include Text; + } + + .desc { + @include S(margin-top, 5px); + @include SuperSmallText; + color: #aaadb2; + } + + > .row { + display: grid; + align-items: center; + grid-template-columns: 1fr auto; + } + + .value.enum { + background: #fff; + @include PlainText; + display: flex; + align-items: flex-start; + pointer-events: all; + cursor: pointer; + justify-content: center; + @include S(min-width, 100px); + @include S(border-radius, 2px); + @include S(padding, 4px); + @include S(padding-right, 15px); + + background: #fff uiResource("icons/enum_selector.png") calc(100% - #{D(5px)}) + calc(50% + #{D(1px)}) / #{D(15px)} no-repeat; + + transition: background-color 0.12s ease-in-out; + &:hover { + background-color: #fafafa; + } + } + } + } +} diff --git a/src/css/textual_game_state.scss b/src/css/textual_game_state.scss index 27cd27ba..ffb2a757 100644 --- a/src/css/textual_game_state.scss +++ b/src/css/textual_game_state.scss @@ -5,263 +5,39 @@ align-items: center; $padding: 15px; - .bottomPoppingInNotification { - position: absolute; - left: 50%; - text-align: center; - @include BoxShadow3D(mix(lighten($mainBgColor, 12), $colorRedBright, 50%)); - @include S(padding, 10px); - max-width: #{D(280px)}; - @include S(bottom, 30px); - box-sizing: border-box; - width: 100%; - @include PlainText; - @include BorderRadius(4px); - - $baseTransform: translateX(-50%); - transform-origin: 0% 100%; - transform: translateY(500%); - opacity: 0; - - display: block; - @include InlineAnimation(5s ease-in-out) { - 0% { - opacity: 0; - transform: scale(0) skew(5deg, 5deg) translateY(100%) $baseTransform; - } - 8% { - transform: scale(1.05) translateY(-2%) $baseTransform; - } - 12% { - transform: scale(1) $baseTransform; - opacity: 1; - } - 97% { - transform: scale(1) $baseTransform; - opacity: 1; - } - 100% { - opacity: 0; - transform: scale(0) skew(5deg, 5deg) translateY(100%) $baseTransform; - } - } + .headerBar, + .content { + @include S(width, 500px); } - .widthKeeper { - width: 100%; - height: 100%; - + .headerBar { display: flex; - flex-direction: column; - overflow: hidden; + align-items: center; - box-sizing: content-box; - @include S(max-width, 1000px); - - @include StyleAtHeight(800px) { - @include S(padding-top, 30px); + h1 { + @include SuperHeading; + text-transform: uppercase; + color: #333438; } - .headerBar { - display: flex; - - @include VerticalStyle { - // margin-top: 1px; - } - - // margin-bottom: 15px; - padding: $padding; - - $h: 25px; - @include S(min-height, $h); - @include S(max-height, $h); - - align-items: center; - justify-content: center; - position: relative; - z-index: 50; - background: transparent; - - @include S(padding-top, $padding); - @include S(padding-left, $padding); - @include S(padding-right, $padding); - background-size: calc(100% - #{D(6px)}) 100%; - $paddingBottom: 20px; - - @include S(padding-bottom, $paddingBottom); - @include S(margin-bottom, -$h - $padding - $paddingBottom); - - h1 { - // text-align: center; - cursor: pointer; - // transform-origin: 0px 50%; - pointer-events: all; - @include S(padding, 5px, 0px, 5px, 30px); - @include S(left, -2px); - @include S(min-width, 100px); - position: relative; - @include IncreasedClickArea(25px); - text-transform: uppercase; - // background: uiResource("back_arrow.png") center center no-repeat; - @include S(background-position-x, -3px); - @include S(background-size, 25px, 25px); - - // Due to back button - color: $text3dColor; - @include TextShadow3D($borderColor: #18151d); - @include SuperHeading; - @include StyleBelowWidth(380px) { - @include Heading; - } - } - - .grow { - flex-grow: 1; - } + .backButton { + @include S(width, 30px); + @include S(height, 30px); + @include S(margin-right, 10px); + @include S(margin-left, -5px); + background: uiResource("icons/state_back_button.png") center center / 70% no-repeat; } - .container { - text-align: left; - flex-direction: column; - pointer-events: all; - box-sizing: border-box; - z-index: 25; - position: relative; - - @include S(padding-left, 0px); - @include S(padding-right, 0px); - - height: 100%; - @include SupportsAndroidNotchQuery { - height: calc( - 100% - constant(safe-area-inset-top) - constant(safe-area-inset-bottom) - - var(--notch-inset-top) - var(--notch-inset-bottom) - ); - } - @include SupportsiOsNotchQuery { - height: calc( - 100% - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) - - var(--notch-inset-top) - var(--notch-inset-bottom) - ); - } - - .loadingIndicator { - display: none; - } - - .errorIndicator { - display: none; - flex-direction: column; - text-align: center; - - .errorInner { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - @include S(max-width, 350px); - - strong { - $col: #ff4564; - @include TextShadow3D($col); - @include Heading; - } - - i { - @include PlainText; - color: #888; - @include S(margin-top, 10px); - display: inline-block; - } - } - } - - .loadingIndicator, - .errorIndicator { - box-sizing: border-box; - justify-content: center; - align-items: center; - height: 100%; - @include S(padding, 30px); - } - - // Loading state - &.loading { - .mainContent { - animation: none; - display: none !important; - } - .loadingIndicator { - display: flex; - } - } - - // Error state - &.errored { - .mainContent { - animation: none; - display: none !important; - } - - .errorIndicator { - animation: none; - display: flex; - } - } - } - - .mainContent { - overflow-y: auto !important; - overflow-x: hidden; - @include S(padding, $padding); - height: 100%; - width: 100%; - box-sizing: border-box; - - @include InlineAnimation(0.4s ease-in-out) { - 0% { - opacity: 0; - } - 100% { - opacity: 1; - } - } - - .category_label { - display: block; - @include S(margin-top, 40px); - - text-transform: uppercase; - &:first-child { - margin-top: 0 !important; - } - - @include S(margin-bottom, 16px); - @include Heading; - - @include TextShadow3D(#68a1bb, $borderColor: #141718); - } - - .cardbox { - @include S(padding, 20px, 15px); - $cardBg: lighten($mainBgColor, 9); - background: $cardBg; - margin-bottom: 15px; - @include S(margin-bottom, 15px); - @include BorderRadius(4px); - @include S(padding-bottom, 14px); - @include BoxShadow3D($cardBg); - - &:last-child { - border-bottom: 0; - } - } - } + @include S(margin-bottom, 20px); } - &.hasTitle { - .mainContent { - @include S(padding-top, 70px, $important: true); - } + .content { + background: #fff; + @include S(border-radius, 2px); + @include S(padding, 10px); + max-height: calc(80vh - #{D(60px)}); + overflow-y: auto; + box-sizing: border-box; + pointer-events: all; } } diff --git a/src/css/variables.scss b/src/css/variables.scss index 5648c0ea..99dc3ea1 100644 --- a/src/css/variables.scss +++ b/src/css/variables.scss @@ -1,3 +1,5 @@ +$globalBorderRadius: 0px; + // When to reduce control elements size for small devices $layoutExpandMinWidth: 340px; diff --git a/src/js/application.js b/src/js/application.js index 1d80c7ce..1832939a 100644 --- a/src/js/application.js +++ b/src/js/application.js @@ -31,6 +31,7 @@ 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"; +import { SettingsState } from "./states/settings"; const logger = createLogger("application"); @@ -142,7 +143,7 @@ export class Application { */ registerStates() { /** @type {Array} */ - const states = [PreloadState, MainMenuState, InGameState]; + const states = [PreloadState, MainMenuState, InGameState, SettingsState]; for (let i = 0; i < states.length; ++i) { this.stateMgr.register(states[i]); diff --git a/src/js/core/config.js b/src/js/core/config.js index 2ce258f9..e7e6c47c 100644 --- a/src/js/core/config.js +++ b/src/js/core/config.js @@ -90,7 +90,7 @@ export const globalConfig = { allBuildingsUnlocked: true, upgradesNoCost: true, disableUnlockDialog: false, - testTranslations: true, + // testTranslations: true, /* dev:end */ }, diff --git a/src/js/core/textual_game_state.js b/src/js/core/textual_game_state.js new file mode 100644 index 00000000..a35d301b --- /dev/null +++ b/src/js/core/textual_game_state.js @@ -0,0 +1,153 @@ +import { HUDModalDialogs } from "../game/hud/parts/modal_dialogs"; +import { GameState } from "./game_state"; +import { T } from "../translations"; + +/** + * Baseclass for all game states which are structured similary: A header with back button + some + * scrollable content. + */ +export class TextualGameState extends GameState { + ///// INTERFACE //// + + /** + * Should return the states inner html. If not overriden, will create a scrollable container + * with the content of getMainContentHTML() + * @returns {string} + */ + getInnerHTML() { + return ` +
+ ${this.getMainContentHTML()} +
+ `; + } + + /** + * Should return the states HTML content. + */ + getMainContentHTML() { + return ""; + } + + /** + * Should return the title of the game state. If null, no title and back button will + * get created + * @returns {string|null} + */ + getStateHeaderTitle() { + return null; + } + + ///////////// + + /** + * Back button handler, can be overridden. Per default it goes back to the main menu, + * or if coming from the game it moves back to the game again. + */ + onBackButton() { + if (this.backToStateId) { + this.moveToState(this.backToStateId, this.backToStatePayload); + } else { + this.moveToState(this.getDefaultPreviousState()); + } + } + + /** + * Returns the default state to go back to + */ + getDefaultPreviousState() { + return "MainMenuState"; + } + + /** + * Goes to a new state, telling him to go back to this state later + * @param {string} stateId + */ + moveToStateAddGoBack(stateId) { + this.moveToState(stateId, { + backToStateId: this.key, + backToStatePayload: { + backToStateId: this.backToStateId, + backToStatePayload: this.backToStatePayload, + }, + }); + } + + /** + * Removes all click detectors, except the one on the back button. Useful when regenerating + * content. + */ + clearClickDetectorsExceptHeader() { + for (let i = 0; i < this.clickDetectors.length; ++i) { + const detector = this.clickDetectors[i]; + if (detector.element === this.headerElement) { + continue; + } + detector.cleanup(); + this.clickDetectors.splice(i, 1); + i -= 1; + } + } + + /** + * Overrides the GameState implementation to provide our own html + */ + internalGetFullHtml() { + let headerHtml = ""; + if (this.getStateHeaderTitle()) { + headerHtml = ` +
+ +

${this.getStateHeaderTitle()}

+
`; + } + + return ` + ${headerHtml} +
+ ${this.getInnerHTML()} + +
+ `; + } + + //// INTERNALS ///// + + /** + * Overrides the GameState leave callback to cleanup stuff + */ + internalLeaveCallback() { + super.internalLeaveCallback(); + this.dialogs.cleanup(); + } + + /** + * Overrides the GameState enter callback to setup required stuff + * @param {any} payload + */ + internalEnterCallback(payload) { + super.internalEnterCallback(payload, false); + if (payload.backToStateId) { + this.backToStateId = payload.backToStateId; + this.backToStatePayload = payload.backToStatePayload; + } + + this.htmlElement.classList.add("textualState"); + if (this.getStateHeaderTitle()) { + this.htmlElement.classList.add("hasTitle"); + } + + this.containerElement = this.htmlElement.querySelector(".widthKeeper .container"); + this.headerElement = this.htmlElement.querySelector(".headerBar > .backButton"); + + if (this.headerElement) { + this.trackClicks(this.headerElement, this.onBackButton); + } + + this.dialogs = new HUDModalDialogs(null, this.app); + const dialogsElement = document.body.querySelector(".modalDialogParent"); + this.dialogs.initializeToElement(dialogsElement); + + this.onEnter(payload); + } +} diff --git a/src/js/game/hud/parts/settings_menu.js b/src/js/game/hud/parts/settings_menu.js index c022ce93..b2c706b7 100644 --- a/src/js/game/hud/parts/settings_menu.js +++ b/src/js/game/hud/parts/settings_menu.js @@ -25,6 +25,10 @@ export class HUDSettingsMenu extends BaseHUDPart { title: T.ingame.settingsMenu.buttons.continue, action: () => this.close(), }, + { + title: T.ingame.settingsMenu.buttons.settings, + action: () => this.goToSettings(), + }, { title: T.ingame.settingsMenu.buttons.menu, action: () => this.returnToMenu(), @@ -47,6 +51,10 @@ export class HUDSettingsMenu extends BaseHUDPart { this.root.gameState.goBackToMenu(); } + goToSettings() { + this.root.gameState.goToSettings(); + } + shouldPauseGame() { return this.visible; } diff --git a/src/js/profile/setting_types.js b/src/js/profile/setting_types.js index 9e11d793..f2f29bc9 100644 --- a/src/js/profile/setting_types.js +++ b/src/js/profile/setting_types.js @@ -3,6 +3,7 @@ import { Application } from "../application"; /* typehints:end */ import { createLogger } from "../core/logging"; +import { T } from "../translations"; const logger = createLogger("setting_types"); @@ -63,8 +64,8 @@ export class BaseSetting { showRestartRequiredDialog() { const { restart } = this.dialogs.showInfo( - "Restart required", - "You need to restart the game to apply the settings.", + T.dialogs.restartRequired.title, + T.dialogs.restartRequired.text, this.app.platformWrapper.getSupportsRestart() ? ["later:grey", "restart:misc"] : ["ok:good"] ); if (restart) { @@ -113,11 +114,11 @@ export class EnumSetting extends BaseSetting { return `
- +
- TODO: SETTING DESC + ${T.settings.labels[this.id].description}
`; } @@ -153,7 +154,7 @@ export class EnumSetting extends BaseSetting { } modify() { - const { optionSelected } = this.dialogs.showOptionChooser("TODO: SETTING TITLE", { + const { optionSelected } = this.dialogs.showOptionChooser(T.settings.labels[this.id].title, { active: this.app.settings.getSetting(this.id), options: this.options.map(option => ({ value: this.valueGetter(option), @@ -186,13 +187,13 @@ export class BoolSetting extends BaseSetting { return `
- +
- TODO: SETTING DESC + ${T.settings.labels[this.id].description}
`; } diff --git a/src/js/states/ingame.js b/src/js/states/ingame.js index 46b86dde..5e0c8d62 100644 --- a/src/js/states/ingame.js +++ b/src/js/states/ingame.js @@ -144,6 +144,13 @@ export class InGameState extends GameState { this.saveThenGoToState("MainMenuState"); } + /** + * Goes back to the settings state + */ + goToSettings() { + this.saveThenGoToState("SettingsState"); + } + /** * Moves to a state outside of the game * @param {string} stateId diff --git a/src/js/states/main_menu.js b/src/js/states/main_menu.js index ccdf32ca..f701fa85 100644 --- a/src/js/states/main_menu.js +++ b/src/js/states/main_menu.js @@ -285,6 +285,12 @@ export class MainMenuState extends GameState { const savegame = this.app.savegameMgr.createNewSavegame(); this.app.analytics.trackUiClick("startgame"); + + if (G_IS_DEV) { + // TODO + this.moveToState("SettingsState"); + } + this.moveToState("InGameState", { savegame, }); diff --git a/src/js/states/settings.js b/src/js/states/settings.js new file mode 100644 index 00000000..48a60273 --- /dev/null +++ b/src/js/states/settings.js @@ -0,0 +1,127 @@ +import { TextualGameState } from "../core/textual_game_state"; +import { formatSecondsToTimeAgo } from "../core/utils"; +import { allApplicationSettings } from "../profile/application_settings"; +import { T } from "../translations"; + +export class SettingsState extends TextualGameState { + constructor() { + super("SettingsState"); + } + + getStateHeaderTitle() { + return T.settings.title; + } + + getMainContentHTML() { + return ` + + + + + ${this.getSettingsHtml()} +
+
${T.global.loading} ...
+ +
+ + + `; + } + + getSettingsHtml() { + let lastCategory = null; + let html = ""; + for (let i = 0; i < allApplicationSettings.length; ++i) { + const setting = allApplicationSettings[i]; + + if (setting.categoryId !== lastCategory) { + lastCategory = setting.categoryId; + if (i !== 0) { + html += ""; + } + html += `${T.settings.categories[lastCategory]}`; + html += "
"; + } + + html += setting.getHtml(); + } + if (lastCategory) { + html += "
"; + } + + return html; + } + + renderBuildText() { + const labelVersion = this.htmlElement.querySelector(".buildVersion"); + const lastBuildMs = new Date().getTime() - G_BUILD_TIME; + const lastBuildText = formatSecondsToTimeAgo(lastBuildMs / 1000.0); + + const version = T.settings.versionBadges[G_APP_ENVIRONMENT]; + + labelVersion.innerHTML = ` + + ${G_BUILD_VERSION} @ ${version} @ ${G_BUILD_COMMIT_HASH} + + + ${T.settings.buildDate.replace("", lastBuildText)}
+
`; + } + + onEnter(payload) { + this.renderBuildText(); + this.trackClicks(this.htmlElement.querySelector(".copyright"), this.onCopyrightClicked, { + preventDefault: false, + }); + this.trackClicks(this.htmlElement.querySelector(".changelog"), this.onChangelogClicked, { + preventDefault: false, + }); + + const keybindingsButton = this.htmlElement.querySelector(".editKeybindings"); + + if (keybindingsButton) { + this.trackClicks(keybindingsButton, this.onKeybindingsClicked, { preventDefault: false }); + } + + this.initSettings(); + } + + initSettings() { + allApplicationSettings.forEach(setting => { + const element = this.htmlElement.querySelector("[data-setting='" + setting.id + "']"); + setting.bind(this.app, element, this.dialogs); + setting.syncValueToElement(); + this.trackClicks( + element, + () => { + setting.modify(); + }, + { preventDefault: false } + ); + }); + } + + onCopyrightClicked() { + // this.moveToStateAddGoBack("CopyrightState"); + } + + onChangelogClicked() { + // this.moveToStateAddGoBack("ChangelogState"); + } + + onKeybindingsClicked() { + // this.moveToStateAddGoBack("KeybindingsState"); + } +} diff --git a/translations/base-en.yaml b/translations/base-en.yaml index 228d1f32..9eb66b86 100644 --- a/translations/base-en.yaml +++ b/translations/base-en.yaml @@ -21,6 +21,7 @@ global: loading: Loading + error: Error # How big numbers are rendered, e.g. "10,000" thousandsDivider: "," @@ -77,6 +78,8 @@ dialogs: ok: OK delete: Delete cancel: Cancel + later: Later + restart: Restart importSavegameError: title: Import Error @@ -103,6 +106,11 @@ dialogs: text: >- Failed to delete the savegame: + restartRequired: + title: Restart required + text: >- + You need to restart the game to apply the settings. + ingame: # This is shown in the top left corner and displays useful keybindings in # every situation @@ -186,6 +194,7 @@ ingame: buttons: continue: Continue + settings: Settings menu: Return to menu # All shop upgrades @@ -257,3 +266,41 @@ storyRewards: # Special reward, which is shown when there is no reward actually no_reward: Next level + +settings: + title: Settings + categories: + game: Game + app: Application + + versionBadges: + dev: Development + staging: Staging + prod: Production + buildDate: Built + + labels: + uiScale: + title: Interface scale + description: >- + Changes the size of the user interface. The interface will still scale based on your device resolution, but this setting controls the amount of scale. + + fullscreen: + title: Fullscreen + description: >- + It is recommended to play the game in fullscreen to get the best experience. Only available in the standalone. + + theme: + title: Interface theme + description: >- + Choose the interface theme which also affects the game. Notice that everything except the default theme may lead to graphical issues. + + soundsMuted: + title: Mute Sounds + description: >- + If enabled, mutes all sound effects. + + musicMuted: + title: Mute Music + description: >- + If enabled, mutes all music.