diff --git a/frontend/package.json b/frontend/package.json index 54c1224..e817939 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -47,6 +47,7 @@ "sass-loader": "^9.0.2", "sdf-2d": "^0.6.0", "shared": "0.0.0", + "resize-observer-polyfill": "^1.5.1", "socket.io-client": "^2.3.1", "source-map-loader": "^1.1.0", "svg-url-loader": "^6.0.0", diff --git a/frontend/src/index.html b/frontend/src/index.html index db4f744..9b8ca6e 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -46,23 +46,44 @@ - open-settings +
+
+ -
-

Settings

- - - -
+ + + + + logout +
+
+ +
+ toggle-settings +
minimize-application maximize-application { - try { - const landingUI = document.querySelector('#landing-ui') as HTMLElement; - const joinGameForm = document.querySelector('#join-game-form') as HTMLFormElement; - const serverContainer = document.querySelector('#server-container') as HTMLElement; - const canvas = document.querySelector('canvas') as HTMLCanvasElement; - const overlay = document.querySelector('#overlay') as HTMLElement; - const settings = document.querySelector('#settings') as HTMLElement; - const openSettings = document.querySelector('#open-settings') as HTMLElement; - const closeSettings = document.querySelector('#close-settings') as HTMLElement; - const minimize = document.querySelector('#minimize') as HTMLElement; - const maximize = document.querySelector('#maximize') as HTMLElement; - const enableRelativeMovementCheckbox = document.querySelector( - '#enable-relative-movement', - ) as HTMLElement; +const landingUI = document.querySelector('#landing-ui') as HTMLElement; +const joinGameForm = document.querySelector('#join-game-form') as HTMLFormElement; +const serverContainer = document.querySelector('#server-container') as HTMLElement; +const canvas = document.querySelector('canvas') as HTMLCanvasElement; +const overlay = document.querySelector('#overlay') as HTMLElement; +const settings = document.querySelector('#settings') as HTMLElement; +const toggleSettingsButton = document.querySelector( + '#toggle-settings-container', +) as HTMLElement; +const minimize = document.querySelector('#minimize') as HTMLElement; +const maximize = document.querySelector('#maximize') as HTMLElement; +const logoutButton = document.querySelector('#logout') as HTMLElement; +const enableRelativeMovement = document.querySelector( + '#enable-relative-movement', +) as HTMLInputElement; +const enableSounds = document.querySelector('#enable-sounds') as HTMLInputElement; +const enableVibration = document.querySelector('#enable-vibration') as HTMLInputElement; - openSettings.addEventListener('click', () => (settings.style.visibility = 'visible')); - closeSettings.addEventListener('click', () => (settings.style.visibility = 'hidden')); +let isInGame = false; - const background = new LandingPageBackground(canvas); - const joinHandler = new JoinFormHandler(joinGameForm, serverContainer); - handleFullScreen(minimize, maximize); - - let backgroundRenderer = await background.renderer; - let isInGame = false; - let game: Game; - const getFrameData = () => { +const startInsights = (getRenderer: () => Renderer | undefined) => { + const { vendor, renderer } = getInsightsFromRenderer(getRenderer()); + handleInsights( + { + vendor, + renderer, + referrer: document.referrer, + connection: (navigator as any)?.connection?.effectiveType, + devicePixelRatio: devicePixelRatio, + }, + () => { const { fps, renderScale, lightScale, canvasWidth, canvasHeight, - } = getInsightsFromRenderer(isInGame ? game.renderer : backgroundRenderer); + } = getInsightsFromRenderer(getRenderer()); return { isInGame, @@ -75,26 +81,75 @@ const main = async () => { canvasWidth, canvasHeight, }; - }; - const { vendor, renderer } = getInsightsFromRenderer(backgroundRenderer); - handleInsights( - { - vendor, - renderer, - referrer: document.referrer, - connection: (navigator as any)?.connection?.effectiveType, - devicePixelRatio: devicePixelRatio, - }, - getFrameData, - ); + }, + ); +}; - const playerDecision = await joinHandler.getPlayerDecision(); - landingUI.style.display = 'none'; +const toggleSettings = () => + (settings.className = settings.className === 'open' ? '' : 'open'); - background.destroy(); +const applyServerContainerShadows = () => { + const { scrollHeight, clientHeight, scrollTop } = serverContainer; + console.log(scrollHeight, clientHeight, scrollTop); + if (scrollHeight > clientHeight) { + serverContainer.className = 'scroll'; + if (scrollTop === 0) { + serverContainer.className += ' top'; + } else if (scrollTop + clientHeight === scrollHeight) { + serverContainer.className += ' bottom'; + } + } +}; - game = new Game(playerDecision, canvas, overlay); - isInGame = true; +const main = async () => { + try { + let game: Game; + + handleFullScreen(minimize, maximize); + toggleSettingsButton.addEventListener('click', toggleSettings); + + new ResizeObserver(applyServerContainerShadows).observe(serverContainer); + serverContainer.addEventListener('scroll', applyServerContainerShadows); + + OptionsHandler.initialize({ + relativeMovementEnabled: enableRelativeMovement, + soundsEnabled: enableSounds, + vibrationEnabled: enableVibration, + }); + + logoutButton.addEventListener('click', () => { + game.destroy(); + toggleSettings(); + }); + window.onpopstate = () => game.destroy(); + + let backgroundRenderer: Renderer | undefined; + startInsights(() => (isInGame ? game.renderer : backgroundRenderer)); + + for (;;) { + landingUI.style.visibility = 'inherit'; + logoutButton.style.visibility = 'hidden'; + + const background = new LandingPageBackground(canvas); + const joinHandler = new JoinFormHandler(joinGameForm, serverContainer); + + backgroundRenderer = await background.renderer; + + const playerDecision = await joinHandler.getPlayerDecision(); + if (!history.state) { + history.pushState(true, ''); + } + + landingUI.style.visibility = 'hidden'; + + background.destroy(); + + logoutButton.style.visibility = 'inherit'; + game = new Game(playerDecision, canvas, overlay); + isInGame = true; + await game.start(); + isInGame = false; + } } catch (e) { console.error(e); alert(e); diff --git a/frontend/src/scripts/commands/generators/keyboard-listener.ts b/frontend/src/scripts/commands/generators/keyboard-listener.ts index 09f2c70..58fc775 100644 --- a/frontend/src/scripts/commands/generators/keyboard-listener.ts +++ b/frontend/src/scripts/commands/generators/keyboard-listener.ts @@ -1,5 +1,6 @@ import { vec2 } from 'gl-matrix'; import { CommandGenerator, MoveActionCommand } from 'shared'; +import { OptionsHandler } from '../../options-handler'; export class KeyboardListener extends CommandGenerator { private keysDown: Set = new Set(); @@ -34,7 +35,9 @@ export class KeyboardListener extends CommandGenerator { if (vec2.squaredLength(movement) > 0) { vec2.normalize(movement, movement); } - this.sendCommandToSubcribers(new MoveActionCommand(movement, true)); + this.sendCommandToSubcribers( + new MoveActionCommand(movement, OptionsHandler.options.relativeMovementEnabled), + ); } private normalize(key: string): string { diff --git a/frontend/src/scripts/commands/generators/touch-listener.ts b/frontend/src/scripts/commands/generators/touch-listener.ts index 7252046..45bc024 100644 --- a/frontend/src/scripts/commands/generators/touch-listener.ts +++ b/frontend/src/scripts/commands/generators/touch-listener.ts @@ -7,6 +7,7 @@ import { MoveActionCommand, } from 'shared'; import { Game } from '../../game'; +import { OptionsHandler } from '../../options-handler'; export class TouchListener extends CommandGenerator { private previousPosition = vec2.create(); @@ -38,7 +39,9 @@ export class TouchListener extends CommandGenerator { if (vec2.squaredLength(movement) > 0) { vec2.normalize(movement, movement); - this.sendCommandToSubcribers(new MoveActionCommand(movement, false)); + this.sendCommandToSubcribers( + new MoveActionCommand(movement, OptionsHandler.options.relativeMovementEnabled), + ); } this.previousPosition = position; @@ -46,7 +49,12 @@ export class TouchListener extends CommandGenerator { target.addEventListener('touchend', (event: TouchEvent) => { event.preventDefault(); - this.sendCommandToSubcribers(new MoveActionCommand(vec2.create(), false)); + this.sendCommandToSubcribers( + new MoveActionCommand( + vec2.create(), + OptionsHandler.options.relativeMovementEnabled, + ), + ); }); } diff --git a/frontend/src/scripts/game.ts b/frontend/src/scripts/game.ts index 45344c0..face16f 100644 --- a/frontend/src/scripts/game.ts +++ b/frontend/src/scripts/game.ts @@ -29,7 +29,7 @@ import { TouchListener } from './commands/generators/touch-listener'; import { CommandReceiverSocket } from './commands/receivers/command-receiver-socket'; import { PlayerDecision } from './join-form-handler'; import { GameObjectContainer } from './objects/game-object-container'; -import { options } from './options'; +import { OptionsHandler } from './options-handler'; import { BlobShape } from './shapes/blob-shape'; import { PlanetShape } from './shapes/planet-shape'; @@ -48,7 +48,6 @@ export class Game { private readonly canvas: HTMLCanvasElement, private readonly overlay: HTMLElement, ) { - this.start(); const progressBar = document.createElement('div'); progressBar.className = 'planet-progress'; overlay.appendChild(progressBar); @@ -71,7 +70,7 @@ export class Game { const command = deserialize(serialized); if (command instanceof PlayerDiedCommand) { this.deadTimeout = command.timeout; - if (options.vibrationEnabled) { + if (OptionsHandler.options.vibrationEnabled) { navigator.vibrate(150); } this.overlay.appendChild(this.announcmentText); @@ -112,10 +111,10 @@ export class Game { ); } - private async start(): Promise { + public async start(): Promise { const noiseTexture = await renderNoise([256, 256], 2, 1); this.setupCommunication(this.playerDecision.server); - runAnimation( + await runAnimation( this.canvas, [ { @@ -162,6 +161,8 @@ export class Game { }, }, ); + this.socket.close(); + this.overlay.innerHTML = ''; } public displayToWorldCoordinates(p: vec2): vec2 { @@ -179,6 +180,11 @@ export class Game { ); } + private isActive = true; + public destroy() { + this.isActive = false; + } + private announcmentText = document.createElement('h2'); private gameLoop( renderer: Renderer, @@ -196,6 +202,6 @@ export class Game { this.gameObjects.stepObjects(deltaTime); this.gameObjects.drawObjects(this.renderer, this.overlay); - return true; + return this.isActive; } } diff --git a/frontend/src/scripts/objects/player-character-view.ts b/frontend/src/scripts/objects/player-character-view.ts index 028f8d7..64aa55a 100644 --- a/frontend/src/scripts/objects/player-character-view.ts +++ b/frontend/src/scripts/objects/player-character-view.ts @@ -8,7 +8,7 @@ import { CharacterTeam, settings, } from 'shared'; -import { options } from '../options'; +import { OptionsHandler } from '../options-handler'; import { BlobShape } from '../shapes/blob-shape'; import { ViewObject } from './view-object'; @@ -50,7 +50,7 @@ export class PlayerCharacterView extends PlayerCharacterBase implements ViewObje this.healthElement.style.width = this.health + '%'; if (this.previousHealth > this.health) { this.previousHealth = this.health; - if (options.vibrationEnabled) { + if (OptionsHandler.options.vibrationEnabled) { navigator.vibrate(50); } } diff --git a/frontend/src/scripts/options-handler.ts b/frontend/src/scripts/options-handler.ts new file mode 100644 index 0000000..1806322 --- /dev/null +++ b/frontend/src/scripts/options-handler.ts @@ -0,0 +1,51 @@ +interface Options { + vibrationEnabled: boolean; + soundsEnabled: boolean; + relativeMovementEnabled: boolean; +} + +export abstract class OptionsHandler { + private static initialized = false; + private static _options: Options = { + vibrationEnabled: true, + soundsEnabled: true, + relativeMovementEnabled: false, + }; + + public static initialize( + inputElements: { [k in Extract]: HTMLInputElement }, + ) { + if (localStorage.getItem('options')) { + const stored: Partial | null = JSON.parse( + localStorage.getItem('options')!, + ); + this._options = { + ...this._options, + ...stored, + }; + } + + for (const k in inputElements) { + const element = inputElements[k as keyof Options]; + element.checked = OptionsHandler._options[k as keyof Options]; + element.addEventListener('change', function () { + OptionsHandler._options[k as keyof Options] = this.checked; + OptionsHandler.save(); + }); + } + + this.initialized = true; + } + + private static save() { + localStorage.setItem('options', JSON.stringify(this._options)); + } + + public static get options(): Options { + if (!this.initialized) { + throw new Error('Uninitialized'); + } + + return this._options; + } +} diff --git a/frontend/src/scripts/options.ts b/frontend/src/scripts/options.ts deleted file mode 100644 index 7c203b1..0000000 --- a/frontend/src/scripts/options.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const options = { - vibrationEnabled: true, -}; diff --git a/frontend/src/styles/button.scss b/frontend/src/styles/button.scss new file mode 100644 index 0000000..faad3bc --- /dev/null +++ b/frontend/src/styles/button.scss @@ -0,0 +1,26 @@ +@import './vars.scss'; + +button { + border: none; + background: none; + font-size: 2rem; + font-family: 'Comfortaa', sans-serif; + cursor: pointer; + display: block; + margin: auto; + + $ascend: 6px; + padding: $medium-padding 0 0 0; + margin-bottom: $ascend; + transition: transform $animation-time, padding-bottom $animation-time, + margin-bottom $animation-time; + + &:hover, + &:focus { + outline: none; + color: $accent; + transform: translateY(-$ascend); + padding-bottom: $ascend; + margin-bottom: 0; + } +} diff --git a/frontend/src/styles/form.scss b/frontend/src/styles/form.scss index 154d393..2bf153f 100644 --- a/frontend/src/styles/form.scss +++ b/frontend/src/styles/form.scss @@ -96,7 +96,7 @@ form { padding: $small-padding; border: $border; cursor: pointer; - margin: $border-width-focused - $border-width; + margin: $border-width-focused - $border-width $small-padding / 2; } &:focus { @@ -107,27 +107,8 @@ form { &:checked + label { border-color: $accent; border-width: $border-width-focused; - margin: 0; + margin-right: 0 $small-padding / 2; } } } } - -button { - border: none; - background: none; - font-size: 2rem; - font-family: 'Comfortaa', sans-serif; - cursor: pointer; - display: block; - margin: auto; - padding-top: $medium-padding; - transition: transform $animation-time; - - &:hover, - &:focus { - outline: none; - color: $accent; - transform: translateY(-8px); - } -} diff --git a/frontend/src/styles/main.scss b/frontend/src/styles/main.scss index d0a3f2b..c5d7fc9 100644 --- a/frontend/src/styles/main.scss +++ b/frontend/src/styles/main.scss @@ -1,8 +1,11 @@ @import './vars.scss'; +@import './button.scss'; @import './form.scss'; @import './mixins.scss'; +@import './settings.scss'; @import url('https://fonts.googleapis.com/css2?family=Comfortaa:wght@300&family=Open+Sans&display=swap'); + * { margin: 0; box-sizing: border-box; @@ -22,8 +25,11 @@ html { } } -h1, -h2 { +img { + user-select: none; +} + +h1 { &, * { font-family: 'Comfortaa', sans-serif; @@ -31,6 +37,10 @@ h2 { font-size: 6rem; text-align: center; padding-bottom: $medium-padding; + + @media (max-height: $height-breakpoint) { + display: none; + } } h2 { @@ -131,30 +141,29 @@ body { } } - #open-settings { - position: absolute; - top: 0; - right: 0; + #server-container { + max-height: 30vh; + overflow-y: auto; - animation: spin 32s linear infinite; - @keyframes spin { - 100% { - transform: rotate(360deg); - } + &::-webkit-scrollbar-track, + &::-webkit-scrollbar { + background-color: transparent; + width: 2px; + } + &::-webkit-scrollbar-thumb { + background-color: $accent; + border-radius: $border-radius; } - } - .icon { - box-sizing: content-box; - - user-select: none; - cursor: pointer; - - padding: $medium-padding; - @include square(48px); - @media (max-width: $breakpoint) { - @include square(32px); - padding: $small-padding; + &.scroll { + box-shadow: inset 0 -8px 8px -8px rgba(0, 0, 0, 0.4), + inset 0 8px 8px -8px rgba(0, 0, 0, 0.4); + &.top { + box-shadow: inset 0 -8px 8px -8px rgba(0, 0, 0, 0.4); + } + &.bottom { + box-shadow: inset 0 8px 8px -8px rgba(0, 0, 0, 0.4); + } } } @@ -162,17 +171,21 @@ body { position: absolute; bottom: 0; left: 0; - } - #settings { - @include background; - position: absolute; - left: 0; - right: 0; - top: 0; - bottom: 0; + box-sizing: content-box; - padding: $large-padding; - visibility: hidden; + user-select: none; + cursor: pointer; + + padding: $medium-padding; + @include square($large-icon); + @media (max-width: $breakpoint) { + @include square($small-icon); + padding: $small-padding; + } + + &:not(:first-child) { + visibility: hidden; + } } } diff --git a/frontend/src/styles/mixins.scss b/frontend/src/styles/mixins.scss index 1afa563..d845573 100644 --- a/frontend/src/styles/mixins.scss +++ b/frontend/src/styles/mixins.scss @@ -6,6 +6,6 @@ @mixin background { backdrop-filter: blur(24px); @supports not (backdrop-filter: blur(24px)) { - background-color: rgba(0, 0, 0, 0.15); + background-color: rgba(0, 0, 0, 0.5); } } diff --git a/frontend/src/styles/settings.scss b/frontend/src/styles/settings.scss new file mode 100644 index 0000000..abb02b0 --- /dev/null +++ b/frontend/src/styles/settings.scss @@ -0,0 +1,122 @@ +@import './vars.scss'; +@import './mixins.scss'; + +#toggle-settings-container, +#settings-container { + position: absolute; + display: flex; + justify-content: center; + + top: 0; + right: 0; +} + +#toggle-settings-container { + padding: $medium-padding; + cursor: pointer; + + #toggle-settings { + @include square($large-icon); + @media (max-width: $breakpoint) { + @include square($small-icon); + } + + animation: spin 32s linear infinite; + @keyframes spin { + 100% { + transform: rotate(360deg); + } + } + } +} + +#settings-container { + top: $medium-padding + $large-icon + $small-padding; + @media (max-width: $breakpoint) { + top: 0; + right: $medium-padding + $small-icon + $small-padding; + } + overflow: hidden; +} + +#settings { + display: flex; + flex-direction: column; + padding: 0 $medium-padding $medium-padding $medium-padding; + + transform: translateY(-100%); + @media (max-width: $breakpoint) { + flex-direction: row-reverse; + transform: translateX(100%); + padding: $medium-padding 0 $medium-padding $medium-padding; + } + + opacity: 0; + transition: transform $animation-time, opacity $animation-time; + + &.open { + transform: none; + opacity: 1; + } + + img { + cursor: pointer; + transition: opacity $animation-time; + margin-bottom: $small-padding; + @include square($large-icon); + @media (max-width: $breakpoint) { + @include square($small-icon); + margin-bottom: 0; + margin-left: $small-padding; + } + } + + label { + position: relative; + } + + input[type='checkbox'] { + width: 0; + height: 0; + appearance: none; + cursor: pointer; + + &:not(:checked) + img { + opacity: 0.4; + } + + &:focus { + outline: none; + } + + &:after { + content: ' '; + background: white; + position: absolute; + + $height: 4px; + width: $height; + top: $height / 2; + + right: 0; + transform-origin: top right; + border-radius: 100px; + height: 0; + transform: rotate(45deg) translateY(17%); + transition: height $animation-time; + + @media (max-width: $breakpoint) { + $height: 2px; + width: $height; + top: $height / 2; + } + } + + &:not(:checked):after { + height: $large-icon; + @media (max-width: $breakpoint) { + height: $small-icon; + } + } + } +} diff --git a/frontend/src/styles/vars.scss b/frontend/src/styles/vars.scss index 7d0be82..7c583fb 100644 --- a/frontend/src/styles/vars.scss +++ b/frontend/src/styles/vars.scss @@ -8,5 +8,8 @@ $small-padding: 12px; $medium-padding: 24px; $large-padding: 64px; $border-radius: 15px; -$animation-time: 200ms; +$animation-time: 200ms ease; $breakpoint: 700px; +$height-breakpoint: 500px; +$large-icon: 48px; +$small-icon: 32px; diff --git a/frontend/static/circle.svg b/frontend/static/circle.svg new file mode 100644 index 0000000..f4fb483 --- /dev/null +++ b/frontend/static/circle.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/static/logout.svg b/frontend/static/logout.svg new file mode 100644 index 0000000..ab168e8 --- /dev/null +++ b/frontend/static/logout.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/static/vibrate.svg b/frontend/static/vibrate.svg new file mode 100644 index 0000000..ce6d594 --- /dev/null +++ b/frontend/static/vibrate.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/static/volume.svg b/frontend/static/volume.svg new file mode 100644 index 0000000..dedc4d8 --- /dev/null +++ b/frontend/static/volume.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file