diff --git a/backend/src/default-options.ts b/backend/src/default-options.ts index 8ae4ab8..465a036 100644 --- a/backend/src/default-options.ts +++ b/backend/src/default-options.ts @@ -4,6 +4,7 @@ export const defaultOptions: Options = { port: 3000, name: 'Localhost', playerLimit: 16, - seed: 51, + seed: Math.random(), + scoreLimit: 500, worldSize: 10000, }; diff --git a/backend/src/game-server.ts b/backend/src/game-server.ts index 1aae894..6d6c047 100644 --- a/backend/src/game-server.ts +++ b/backend/src/game-server.ts @@ -6,6 +6,12 @@ import { settings, ServerInformation, PlayerInformation, + serializable, + UpdateGameState, + serialize, + CharacterTeam, + GameEnd, + GameStart, } from 'shared'; import { createWorld } from './map/create-world'; import { DeltaTimeCalculator } from './helper/delta-time-calculator'; @@ -15,22 +21,40 @@ import { PlayerContainer } from './players/player-container'; const playerCountSubscribedRoom = 'playerCountUpdates'; export class GameServer { - private objects = new PhysicalContainer(); - private players: PlayerContainer; - private deltaTimes: Array = []; - private deltaTimeCalculator = new DeltaTimeCalculator(); + private objects!: PhysicalContainer; + private players!: PlayerContainer; + private deltaTimes!: Array; + private deltaTimeCalculator!: DeltaTimeCalculator; + + private declaPoints = 0; + private redPoints = 0; + + private isInEndGame = false; + private timeScaling = 1; private serverName: string; private playerLimit: number; - constructor(private readonly io: ioserver.Server, options: Options) { + private initialize() { + const previousPlayers = this.players; + this.objects = new PhysicalContainer(); this.players = new PlayerContainer(this.objects); + this.deltaTimeCalculator = new DeltaTimeCalculator(); + this.deltaTimes = []; + this.declaPoints = 0; + this.redPoints = 0; + this.isInEndGame = false; + this.timeScaling = 1; + createWorld(this.objects, this.options.worldSize); + this.objects.initialize(); + previousPlayers?.sendOnSocket(serialize(new GameStart())); + } + constructor(private readonly io: ioserver.Server, private options: Options) { this.serverName = options.name; this.playerLimit = options.playerLimit; - createWorld(this.objects, options.worldSize); - this.objects.initialize(); + this.initialize(); io.on('connection', (socket: SocketIO.Socket) => { socket.on(TransportEvents.PlayerJoining, (playerInfo: PlayerInformation) => { @@ -40,33 +64,61 @@ export class GameServer { player.sendCommand(command); }); - this.sendPlayerCountUpdate(); + this.sendServerStateUpdate(); socket.on('disconnect', () => { player.destroy(); this.players.deletePlayer(player); - this.sendPlayerCountUpdate(); + this.sendServerStateUpdate(); }); }); - socket.on(TransportEvents.SubscribeForPlayerCount, () => { + socket.on(TransportEvents.SubscribeForServerInfoUpdates, () => { socket.join(playerCountSubscribedRoom); }); }); } - public sendPlayerCountUpdate() { + private timeSinceLastServerStateUpdate = 0; + public sendServerStateUpdate() { this.io .to(playerCountSubscribedRoom) - .emit(TransportEvents.PlayerCountUpdate, this.players.count); + .emit(TransportEvents.ServerInfoUpdate, [this.players.count, this.gameProgress]); } public start() { this.handlePhysics(); } + private updatePoints() { + if (this.isInEndGame) { + return; + } + const { decla, red } = this.objects.getPointsGenerated(); + this.declaPoints += decla; + this.redPoints += red; + if (this.declaPoints >= this.options.scoreLimit) { + this.endGame(CharacterTeam.decla); + } else if (this.redPoints >= this.options.scoreLimit) { + this.endGame(CharacterTeam.red); + } + } + + private endGame(winningTeam: CharacterTeam) { + this.isInEndGame = true; + const endTitleLength = 6000; + this.players.sendOnSocket( + serialize(new GameEnd(winningTeam, endTitleLength / 1000, true)), + ); + setTimeout(() => this.destroy(), endTitleLength * 1.1); + } + + private destroy() { + this.initialize(); + } + private handlePhysics() { - const delta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds(); + let delta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds(); const framesBetweenDeltaTimeCalculation = 1000; if (this.deltaTimes.length > framesBetweenDeltaTimeCalculation) { @@ -82,8 +134,25 @@ export class GameServer { this.deltaTimes = []; } + if ((this.timeSinceLastServerStateUpdate += delta) > 5) { + this.timeSinceLastServerStateUpdate = 0; + this.sendServerStateUpdate(); + } + + if (this.isInEndGame) { + this.timeScaling *= Math.pow(settings.endGameDeltaScaling, delta); + delta /= this.timeScaling; + } + this.objects.stepObjects(delta); + this.updatePoints(); + this.players.sendOnSocket( + serialize( + new UpdateGameState(this.declaPoints, this.redPoints, this.options.scoreLimit), + ), + ); this.players.step(delta); + this.objects.resetRemoteCalls(); const physicsDelta = this.deltaTimeCalculator.getDeltaTimeInSeconds() * 1000; this.deltaTimes.push(physicsDelta); @@ -96,11 +165,16 @@ export class GameServer { } } + private get gameProgress(): number { + return (Math.max(this.declaPoints, this.redPoints) / this.options.scoreLimit) * 100; + } + public get serverInfo(): ServerInformation { return { serverName: this.serverName, playerCount: this.players.count, playerLimit: this.playerLimit, + gameStatePercent: this.gameProgress, }; } } diff --git a/backend/src/main.ts b/backend/src/main.ts index d1a3abd..c4a57a1 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -4,11 +4,8 @@ import { Server } from 'http'; import cors from 'cors'; import { applyArrayPlugins, Random, serverInformationEndpoint } from 'shared'; import minimist from 'minimist'; - import { glMatrix } from 'gl-matrix'; - import { GameServer } from './game-server'; - import { defaultOptions } from './default-options'; glMatrix.setMatrixArrayType(Array); diff --git a/backend/src/objects/circle-physical.ts b/backend/src/objects/circle-physical.ts index 6e1afec..daf6191 100644 --- a/backend/src/objects/circle-physical.ts +++ b/backend/src/objects/circle-physical.ts @@ -1,5 +1,5 @@ import { vec2 } from 'gl-matrix'; -import { Circle, GameObject, serializesTo, settings, UpdateObjectMessage } from 'shared'; +import { Circle, GameObject, serializesTo, settings } from 'shared'; import { PhysicalBase } from '../physics/physicals/physical-base'; import { BoundingBox } from '../physics/bounding-boxes/bounding-box'; import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base'; diff --git a/backend/src/objects/generates-points.ts b/backend/src/objects/generates-points.ts new file mode 100644 index 0000000..053f89a --- /dev/null +++ b/backend/src/objects/generates-points.ts @@ -0,0 +1,8 @@ +export interface GeneratesPoints { + getPoints(): { + decla: number; + red: number; + }; +} + +export const generatesPoints = (a: any): a is GeneratesPoints => a && 'getPoints' in a; diff --git a/backend/src/objects/lamp-physical.ts b/backend/src/objects/lamp-physical.ts index b10a1cb..43ed4a5 100644 --- a/backend/src/objects/lamp-physical.ts +++ b/backend/src/objects/lamp-physical.ts @@ -36,6 +36,8 @@ export class LampPhysical extends LampBase implements StaticPhysical { return vec2.create(); } + public step(deltaTime: number): void {} + public distance(target: vec2): number { return vec2.distance(this.center, target); } diff --git a/backend/src/objects/planet-physical.ts b/backend/src/objects/planet-physical.ts index 561a4c4..38d29bd 100644 --- a/backend/src/objects/planet-physical.ts +++ b/backend/src/objects/planet-physical.ts @@ -10,15 +10,16 @@ import { settings, PlanetBase, CharacterTeam, - UpdateObjectMessage, } from 'shared'; import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box'; import { StaticPhysical } from '../physics/physicals/static-physical'; -import { UpdateGameObjectMessage } from '../update-game-object-message'; +import { GeneratesPoints } from './generates-points'; @serializesTo(PlanetBase) -export class PlanetPhysical extends PlanetBase implements StaticPhysical { +export class PlanetPhysical + extends PlanetBase + implements StaticPhysical, GeneratesPoints { public readonly canCollide = true; public readonly canMove = false; @@ -67,41 +68,62 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { return sign * d; } - public calculateUpdates(): UpdateObjectMessage { - return new UpdateGameObjectMessage(this, ['ownership']); + public get team(): CharacterTeam { + return this.ownership === 0.5 + ? CharacterTeam.neutral + : this.ownership < 0.5 + ? CharacterTeam.decla + : CharacterTeam.red; + } + + private timeSinceLastPointGeneration = 0; + public getPoints(): { + decla: number; + red: number; + } { + if (this.timeSinceLastPointGeneration > settings.planetPointGenerationInterval) { + this.timeSinceLastPointGeneration = 0; + if (this.team !== CharacterTeam.neutral) { + this.remoteCall('generatedPoints', settings.planetPointGenerationValue); + } + + return { + decla: + this.team === CharacterTeam.decla ? settings.planetPointGenerationValue : 0, + red: this.team === CharacterTeam.red ? settings.planetPointGenerationValue : 0, + }; + } + + return { + decla: 0, + red: 0, + }; + } + + public step(deltaTime: number): void { + this.timeSinceLastPointGeneration += deltaTime; + + // In reverse order, so that teams can achieve a 100% control. + this.remoteCall('setOwnership', this.ownership); + this.takeControl(CharacterTeam.neutral, deltaTime); } public takeControl(team: CharacterTeam, deltaTime: number) { - const previousOwnership = this.ownership; if (team === CharacterTeam.decla) { this.ownership -= (0.5 / settings.takeControlTimeInSeconds) * deltaTime; - if ( - previousOwnership >= 0.5 - settings.planetControlThreshold && - this.ownership < 0.5 - settings.planetControlThreshold - ) { - PlanetPhysical.declaPlanetCount++; - PlanetPhysical.neutralPlanetCount--; - } else if ( - previousOwnership > 0.5 + settings.planetControlThreshold && - previousOwnership <= 0.5 + settings.planetControlThreshold - ) { - PlanetPhysical.redPlanetCount--; - PlanetPhysical.neutralPlanetCount++; - } - } else { + } else if (team === CharacterTeam.red) { this.ownership += (0.5 / settings.takeControlTimeInSeconds) * deltaTime; + } else { + const previous = this.ownership; + this.ownership += + -Math.sign(this.ownership - 0.5) * + (0.5 / settings.loseControlTimeInSeconds) * + deltaTime; if ( - previousOwnership < 0.5 + settings.planetControlThreshold && - this.ownership >= 0.5 + settings.planetControlThreshold + (previous < 0.5 && this.ownership > 0.5) || + (previous > 0.5 && this.ownership < 0.5) ) { - PlanetPhysical.redPlanetCount++; - PlanetPhysical.neutralPlanetCount--; - } else if ( - previousOwnership <= 0.5 - settings.planetControlThreshold && - previousOwnership > 0.5 + settings.planetControlThreshold - ) { - PlanetPhysical.declaPlanetCount--; - PlanetPhysical.neutralPlanetCount++; + this.ownership = 0.5; } } diff --git a/backend/src/objects/player-character-physical.ts b/backend/src/objects/player-character-physical.ts index 7703920..0813004 100644 --- a/backend/src/objects/player-character-physical.ts +++ b/backend/src/objects/player-character-physical.ts @@ -21,8 +21,6 @@ import { forceAtPosition } from '../physics/functions/force-at-position'; import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; import { PlanetPhysical } from './planet-physical'; import { ReactsToCollision } from '../physics/physicals/reacts-to-collision'; -import { UpdateObjectMessage } from 'shared/lib/src/objects/update-object-message'; -import { UpdateGameObjectMessage } from '../update-game-object-message'; @serializesTo(PlayerCharacterBase) export class PlayerCharacterPhysical @@ -133,20 +131,15 @@ export class PlayerCharacterPhysical ); } - public calculateUpdates(): UpdateObjectMessage { - return new UpdateGameObjectMessage(this, [ - 'head', - 'leftFoot', - 'rightFoot', - 'health', - 'killCount', - ]); - } - public handleMovementAction(c: MoveActionCommand) { this.movementActions.push(c); } + private addKill() { + this.killCount++; + this.remoteCall('setKillCount', this.killCount); + } + public onCollision(other: GameObject) { if ( other instanceof ProjectilePhysical && @@ -155,9 +148,10 @@ export class PlayerCharacterPhysical ) { other.destroy(); this.health -= other.strength; + this.remoteCall('setHealth', this.health); if (this.health <= 0) { this.destroy(); - other.originator.killCount++; + other.originator.addKill(); } } } @@ -182,6 +176,8 @@ export class PlayerCharacterPhysical this.container, ); this.container.addObject(projectile); + + this.remoteCall('onShoot', strength); } public get boundingBox(): BoundingBoxBase { @@ -299,6 +295,7 @@ export class PlayerCharacterPhysical this.stepBodyPart(this.leftFoot, deltaTime); this.stepBodyPart(this.rightFoot, deltaTime); this.keepPosture(); + this.remoteCall('updateCircles', this.head, this.leftFoot, this.rightFoot); } private setDirection(direction: vec2, deltaTime: number) { diff --git a/backend/src/objects/projectile-physical.ts b/backend/src/objects/projectile-physical.ts index 95b160d..18f421b 100644 --- a/backend/src/objects/projectile-physical.ts +++ b/backend/src/objects/projectile-physical.ts @@ -13,8 +13,6 @@ import { DynamicPhysical } from '../physics/physicals/dynamic-physical'; import { PhysicalContainer } from '../physics/containers/physical-container'; import { PlanetPhysical } from './planet-physical'; import { ReactsToCollision } from '../physics/physicals/reacts-to-collision'; -import { UpdateObjectMessage } from 'shared/lib/src/objects/update-object-message'; -import { UpdateGameObjectMessage } from '../update-game-object-message'; import { PlayerCharacterPhysical } from './player-character-physical'; import { moveCircle } from '../physics/functions/move-circle'; @@ -68,10 +66,6 @@ export class ProjectilePhysical vec2.add(this.center, this.center, delta); } - public calculateUpdates(): UpdateObjectMessage { - return new UpdateGameObjectMessage(this, ['center', 'strength']); - } - public get boundingBox(): ImmutableBoundingBox { if (!this._boundingBox) { this._boundingBox = (this.object as CirclePhysical).boundingBox; @@ -105,7 +99,9 @@ export class ProjectilePhysical } public step(deltaTime: number) { - if ((this.strength -= settings.projectileFadeSpeed * deltaTime) < 0) { + super.step(deltaTime); + + if (this.strength <= 0) { this.destroy(); return; } @@ -122,5 +118,7 @@ export class ProjectilePhysical this.object.velocity = this.velocity; this.object.step2(deltaTime); + + this.remoteCall('setCenter', this.center); } } diff --git a/backend/src/options.ts b/backend/src/options.ts index 3b738a5..67d63b9 100644 --- a/backend/src/options.ts +++ b/backend/src/options.ts @@ -2,6 +2,7 @@ export interface Options { port: number; name: string; playerLimit: number; + scoreLimit: number; seed: number; worldSize: number; } diff --git a/backend/src/physics/containers/physical-container.ts b/backend/src/physics/containers/physical-container.ts index 80ad925..f53e300 100644 --- a/backend/src/physics/containers/physical-container.ts +++ b/backend/src/physics/containers/physical-container.ts @@ -6,12 +6,14 @@ import { BoundingBoxTree } from './bounding-box-tree'; import { Physical } from '../physicals/physical'; import { StaticPhysical } from '../physicals/static-physical'; import { DynamicPhysical } from '../physicals/dynamic-physical'; +import { GeneratesPoints, generatesPoints } from '../../objects/generates-points'; export class PhysicalContainer { private isTreeInitialized = false; private staticBoundingBoxesWaitList: Array = []; private staticBoundingBoxes = new BoundingBoxTree(); private dynamicBoundingBoxes = new BoundingBoxList(); + private objects: Array = []; public initialize() { this.staticBoundingBoxes.build(this.staticBoundingBoxesWaitList); @@ -19,6 +21,8 @@ export class PhysicalContainer { } public addObject(physical: Physical) { + this.objects.push(physical); + if (physical.canMove) { this.dynamicBoundingBoxes.insert(physical); } else { @@ -31,11 +35,31 @@ export class PhysicalContainer { } public removeObject(object: DynamicPhysical) { + this.objects = this.objects.filter((p) => p !== object); this.dynamicBoundingBoxes.remove(object); } public stepObjects(deltaTime: number) { - this.dynamicBoundingBoxes.forEach((o) => o.step(deltaTime)); + this.objects.forEach((o) => o.step(deltaTime)); + } + + public resetRemoteCalls() { + this.objects.forEach((o) => o.gameObject.resetRemoteCalls()); + } + + public getPointsGenerated(): { decla: number; red: number } { + return this.objects + .filter((o) => generatesPoints(o)) + .reduce( + (sum, o) => { + const { decla, red } = ((o as unknown) as GeneratesPoints).getPoints(); + return { + decla: sum.decla + decla, + red: sum.red + red, + }; + }, + { decla: 0, red: 0 }, + ); } public findIntersecting(box: BoundingBoxBase): Array { diff --git a/backend/src/physics/physicals/dynamic-physical.ts b/backend/src/physics/physicals/dynamic-physical.ts index a5e03c1..d72cd9d 100644 --- a/backend/src/physics/physicals/dynamic-physical.ts +++ b/backend/src/physics/physicals/dynamic-physical.ts @@ -2,5 +2,4 @@ import { PhysicalBase } from './physical-base'; export interface DynamicPhysical extends PhysicalBase { readonly canMove: true; - step(deltaTimeInMilliseconds: number): void; } diff --git a/backend/src/physics/physicals/physical-base.ts b/backend/src/physics/physicals/physical-base.ts index 66b0018..ba0a753 100644 --- a/backend/src/physics/physicals/physical-base.ts +++ b/backend/src/physics/physicals/physical-base.ts @@ -9,5 +9,5 @@ export interface PhysicalBase { readonly gameObject: GameObject; distance(target: vec2): number; - step(deltaTimeInMilliseconds: number): void; + step(deltaTime: number): void; } diff --git a/backend/src/players/player-container.ts b/backend/src/players/player-container.ts index 2f8dbdd..ec42e1c 100644 --- a/backend/src/players/player-container.ts +++ b/backend/src/players/player-container.ts @@ -1,4 +1,4 @@ -import { CharacterTeam, PlayerInformation, Random } from 'shared'; +import { CharacterTeam, PlayerInformation, Random, TransportEvents } from 'shared'; import { PhysicalContainer } from '../physics/containers/physical-container'; import { Player } from './player'; @@ -32,6 +32,10 @@ export class PlayerContainer { this.players.forEach((p) => p.step(deltaTimeInSeconds)); } + public sendOnSocket(message: any) { + this.players.forEach((p) => p.socket.emit(TransportEvents.ServerToPlayer, message)); + } + private getTeamOfNextPlayer(): CharacterTeam { const declaCount = this._players.filter((p) => p.team === CharacterTeam.decla).length; const redCount = this._players.filter((p) => p.team === CharacterTeam.red).length; diff --git a/backend/src/players/player.ts b/backend/src/players/player.ts index 86eaab7..5cc3649 100644 --- a/backend/src/players/player.ts +++ b/backend/src/players/player.ts @@ -8,7 +8,6 @@ import { MoveActionCommand, serialize, TransportEvents, - UpdateObjectsCommand, SetAspectRatioActionCommand, calculateViewArea, SecondaryActionCommand, @@ -17,11 +16,13 @@ import { Circle, PlayerInformation, CharacterTeam, - UpdateGameState, + UpdateOtherPlayerDirections, GameObject, Command, - UpdateObjectMessage, OtherPlayerDirection, + RemoteCallsForObject, + RemoteCallsForObjects, + ServerAnnouncement, } from 'shared'; import { getTimeInMilliseconds } from '../helper/get-time-in-milliseconds'; import { BoundingBox } from '../physics/bounding-boxes/bounding-box'; @@ -70,7 +71,7 @@ export class Player extends CommandReceiver { private readonly playerInfo: PlayerInformation, private readonly playerContainer: PlayerContainer, private readonly objectContainer: PhysicalContainer, - private readonly socket: SocketIO.Socket, + public readonly socket: SocketIO.Socket, public readonly team: CharacterTeam, ) { super(); @@ -123,8 +124,15 @@ export class Player extends CommandReceiver { this.character = null; this.timeUntilRespawn = settings.playerDiedTimeout; } - } else if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) { - this.createCharacter(); + } else { + this.sendToPlayer( + new ServerAnnouncement(`Reviving in ${Math.round(this.timeUntilRespawn)}…`), + ); + if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) { + this.createCharacter(); + this.center = this.character!.center; + this.sendToPlayer(new ServerAnnouncement('')); + } } const viewArea = calculateViewArea(this.center, this.aspectRatio, 1.5); @@ -155,21 +163,14 @@ export class Player extends CommandReceiver { } this.sendToPlayer( - new UpdateObjectsCommand( - this.objectsPreviouslyInViewArea - .map((g) => g.calculateUpdates()) - .filter((u) => u) as Array, + new RemoteCallsForObjects( + this.objectsPreviouslyInViewArea.map( + (g) => new RemoteCallsForObject(g.id, g.getRemoteCalls()), + ), ), ); - this.sendToPlayer( - new UpdateGameState( - PlanetPhysical.declaPlanetCount, - PlanetPhysical.redPlanetCount, - PlanetPhysical.neutralPlanetCount, - this.getOtherPlayers(), - ), - ); + this.sendToPlayer(new UpdateOtherPlayerDirections(this.getOtherPlayers())); } private findEmptyPositionForPlayer(): vec2 { @@ -223,7 +224,7 @@ export class Player extends CommandReceiver { .filter((g) => g instanceof PlayerCharacterPhysical); const otherPlayers = this.playerContainer.players.filter( - (p) => playersInViewArea.indexOf(p.character!) < 0, + (p) => p.character?.isAlive && playersInViewArea.indexOf(p.character!) < 0, ); return otherPlayers.map( @@ -244,7 +245,6 @@ export class Player extends CommandReceiver { public destroy() { this.isActive = false; - this.character?.destroy(); } } diff --git a/backend/src/update-game-object-message.ts b/backend/src/update-game-object-message.ts deleted file mode 100644 index 181ab30..0000000 --- a/backend/src/update-game-object-message.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { GameObject, serializesTo, UpdateMessage, UpdateObjectMessage } from 'shared'; - -@serializesTo(UpdateObjectMessage) -export class UpdateGameObjectMessage extends UpdateObjectMessage { - constructor(object: T, keys: Array) { - super( - object.id, - keys.map((k) => new UpdateMessage(k, object[k])), - ); - } -} diff --git a/frontend/src/index.ts b/frontend/src/index.ts index db372bd..7bdad78 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -108,7 +108,12 @@ const applyServerContainerShadows = () => { const main = async () => { try { let game: Game; - SoundHandler.initialize(); + + const firstClickListener = () => { + SoundHandler.initialize(); + document.removeEventListener('click', firstClickListener); + }; + document.addEventListener('click', firstClickListener); handleFullScreen(minimize, maximize); toggleSettingsButton.addEventListener('click', toggleSettings); @@ -134,7 +139,7 @@ const main = async () => { for (;;) { show(spinner); - hide(logoutButton); + hide(logoutButton, true); show(landingUI, true, 'flex'); const background = new LandingPageBackground(canvas); @@ -156,7 +161,7 @@ const main = async () => { await game.started; isInGame = true; hide(spinner); - show(logoutButton); + show(logoutButton, true, 'block'); await gameOver; isInGame = false; } diff --git a/frontend/src/main.scss b/frontend/src/main.scss index ffc2012..52be356 100644 --- a/frontend/src/main.scss +++ b/frontend/src/main.scss @@ -37,7 +37,9 @@ h1 { font-size: 6rem; text-align: center; padding-bottom: $medium-padding; - + @media (max-width: $height-breakpoint) { + font-size: 4.5rem; + } @media (max-height: $height-breakpoint) { display: none; } @@ -163,11 +165,23 @@ body { font-size: 0; transform: translateX(-50%) translateY(-50%); + box-shadow: inset 0 0 3px 0px rgba(0, 0, 0, 0.2); + @include square(50px); border-radius: 1000px; mask-image: url('../static/mask.svg'); } + .falling-point { + font-size: 1.2em; + &.decla { + color: $bright-decla; + } + &.red { + color: $bright-red; + } + } + .other-player-arrow { @include square($large-icon); @media (max-width: $breakpoint) { @@ -205,18 +219,29 @@ body { } .announcement { - top: 50%; - left: 50%; - transform: translateX(-50%) translateY(-50%); + top: 25%; + transform: translateX(calc(-50% + 50vw)) translateY(-50%); + font-size: 3rem; @include background; z-index: 1000; padding: $medium-padding; border-radius: 16px; + + &:empty { + display: none; + } + + .decla { + color: $bright-decla; + } + + .red { + color: $bright-red; + } } .planet-progress { - position: absolute; top: $small-padding; left: 50%; transform: translateX(-50%); @@ -225,26 +250,50 @@ body { $height: 8px; height: $height; + justify-content: space-between; + box-shadow: inset 0 0 3px 0px rgba(0, 0, 0, 0.2); + border-radius: 4px; z-index: 100; - div { - height: $height; + &::before { + content: ''; + position: absolute; + left: 50%; + top: 50%; + transform: translateX(-50%) translateY(-50%); + background-color: #888; + height: 24px; + width: 4px; + border-radius: 1000px; } - border-radius: 100px; - overflow: hidden; + &::after { + content: ''; + position: absolute; + left: 50%; + top: 50%; + transform: translateX(-50%) translateY(50%); + + @include square(24px); + + background-image: url('../static/flag.svg'); + background-size: contain; + } + + div { + height: $height; + box-shadow: inset 0 0 3px 0px rgba(0, 0, 0, 0.2); + } div:nth-child(1) { background: $bright-decla; + border-radius: 100px 0 0 100px; } div:nth-child(2) { - background: $bright-neutral; - } - - div:nth-child(3) { background: $bright-red; + border-radius: 0 100px 100px 0; } } } diff --git a/frontend/src/scripts/game.ts b/frontend/src/scripts/game.ts index e11db3f..d26fa09 100644 --- a/frontend/src/scripts/game.ts +++ b/frontend/src/scripts/game.ts @@ -1,156 +1,95 @@ import { vec2 } from 'gl-matrix'; -import { - CircleLight, - ColorfulCircle, - FilteringOptions, - Flashlight, - Renderer, - renderNoise, - runAnimation, - WrapOptions, -} from 'sdf-2d'; +import { Renderer, renderNoise } from 'sdf-2d'; import { broadcastCommands, deserialize, serialize, - settings, TransportEvents, SetAspectRatioActionCommand, PlayerInformation, PlayerDiedCommand, - UpdateGameState, + UpdateOtherPlayerDirections, clamp, + UpdateGameState, + GameEnd, + CharacterTeam, + ServerAnnouncement, + GameStart, + CommandReceiver, + CommandExecutors, + Command, } from 'shared'; import io from 'socket.io-client'; import { KeyboardListener } from './commands/generators/keyboard-listener'; import { MouseListener } from './commands/generators/mouse-listener'; import { TouchJoystickListener } from './commands/generators/touch-joystick-listener'; import { CommandReceiverSocket } from './commands/receivers/command-receiver-socket'; +import { startAnimation } from './start-animation'; import { PlayerDecision } from './join-form-handler'; import { GameObjectContainer } from './objects/game-object-container'; import { OptionsHandler } from './options-handler'; -import { BlobShape } from './shapes/blob-shape'; -import { PlanetShape } from './shapes/planet-shape'; -export class Game { - public readonly gameObjects = new GameObjectContainer(this); +export class Game extends CommandReceiver { + public gameObjects = new GameObjectContainer(this); public renderer?: Renderer; private socket!: SocketIOClient.Socket; - private deadTimeout = 0; + private isBetweenGames = false; + + public started: Promise; + private resolveStarted!: () => unknown; private declaPlanetCountElement = document.createElement('div'); private redPlanetCountElement = document.createElement('div'); - private neutralPlanetCountElement = document.createElement('div'); private announcementText = document.createElement('h2'); + private progressBar = document.createElement('div'); + private arrowElements: Array = []; constructor( private readonly playerDecision: PlayerDecision, private readonly canvas: HTMLCanvasElement, private readonly overlay: HTMLElement, ) { + super(); + this.started = new Promise((r) => (this.resolveStarted = r)); this.announcementText.className = 'announcement'; - const progressBar = document.createElement('div'); - progressBar.className = 'planet-progress'; - overlay.appendChild(progressBar); - progressBar.appendChild(this.declaPlanetCountElement); - progressBar.appendChild(this.neutralPlanetCountElement); - progressBar.appendChild(this.redPlanetCountElement); + this.progressBar.className = 'planet-progress'; + this.progressBar.appendChild(this.declaPlanetCountElement); + this.progressBar.appendChild(this.redPlanetCountElement); } - private arrowElements: Array = []; - private async setupCommunication(serverUrl: string): Promise { - this.socket = io(serverUrl, { + private initialize() { + this.isBetweenGames = true; + + this.socket?.close(); + this.gameObjects = new GameObjectContainer(this); + this.overlay.innerHTML = ''; + this.overlay.appendChild(this.progressBar); + this.announcementText.innerText = ''; + this.overlay.appendChild(this.announcementText); + + this.socket = io(this.playerDecision.server, { reconnectionDelayMax: 10000, transports: ['websocket'], + forceNew: true, }); this.socket.on('reconnect_attempt', () => { this.socket.io.opts.transports = ['polling', 'websocket']; }); - this.socket.on('disconnect', this.destroy.bind(this)); - - this.socket.on(TransportEvents.ServerToPlayer, (serialized: string) => { - const command = deserialize(serialized); - if (command instanceof PlayerDiedCommand) { - this.deadTimeout = command.timeout; - if (OptionsHandler.options.vibrationEnabled) { - navigator.vibrate(150); - } - this.overlay.appendChild(this.announcementText); - } else if (command instanceof UpdateGameState) { - const all = command.declaCount + command.redCount + command.neutralCount; - this.declaPlanetCountElement.style.width = (command.declaCount / all) * 100 + '%'; - this.neutralPlanetCountElement.style.width = - (command.neutralCount / all) * 100 + '%'; - this.redPlanetCountElement.style.width = (command.redCount / all) * 100 + '%'; - - if (command.declaCount > all * 0.5) { - this.overlay.appendChild(this.announcementText); - this.announcementText.innerText = 'Decla team won 🎉'; - } - - if (command.redCount > all * 0.5) { - this.overlay.appendChild(this.announcementText); - this.announcementText.innerText = 'Red team won 🎉'; - } - - this.arrowElements - .splice(command.otherPlayerDirections.length, this.arrowElements.length) - .forEach((e) => e.parentElement?.removeChild(e)); - - for ( - let i = this.arrowElements.length; - i < command.otherPlayerDirections.length; - i++ - ) { - const element = document.createElement('div'); - this.arrowElements.push(element); - this.overlay.appendChild(element); - } - - this.arrowElements.forEach((e, i) => { - const direction = command.otherPlayerDirections[i].direction; - const team = command.otherPlayerDirections[i].team; - const angle = Math.atan2(direction.y, direction.x); - e.className = 'other-player-arrow ' + team; - - const { width, height } = this.overlay.getBoundingClientRect(); - const aspectRatio = width / height; - const directionRatio = direction.x / direction.y; - - let deltaX: number, deltaY: number; - if (aspectRatio < Math.abs(directionRatio)) { - deltaX = (width / 2) * Math.sign(direction.x); - deltaY = deltaX / directionRatio; - } else { - deltaY = (height / 2) * Math.sign(direction.y); - deltaX = deltaY * directionRatio; - } - - const delta = vec2.fromValues(deltaX, deltaY); - const center = vec2.fromValues(width / 2, height / 2); - const p = vec2.add(center, center, delta); - const arrowPadding = 24; - vec2.set( - p, - clamp(p.x, arrowPadding, width - arrowPadding), - clamp(height - p.y, arrowPadding, height - arrowPadding), - ); - e.style.transform = `translateX(${p.x}px) translateY(${ - p.y - }px) translateX(-50%) translateY(-50%) rotate(${-angle + Math.PI / 2}rad) `; - }); - } else this.gameObjects.sendCommand(command); + this.socket.on('disconnect', () => { + if (!this.isBetweenGames) { + this.destroy(); + } }); this.socket.on(TransportEvents.Ping, () => { this.socket.emit(TransportEvents.Pong); }); - this.socket.emit(TransportEvents.PlayerJoining, { - name: this.playerDecision.playerName, - } as PlayerInformation); + this.socket.on(TransportEvents.ServerToPlayer, (serialized: string) => + this.sendCommand(deserialize(serialized)), + ); broadcastCommands( [ @@ -160,57 +99,94 @@ export class Game { ], [this.gameObjects, new CommandReceiverSocket(this.socket)], ); + + this.isBetweenGames = false; + + this.socket.emit(TransportEvents.PlayerJoining, { + name: this.playerDecision.playerName, + } as PlayerInformation); + } + + protected defaultCommandExecutor(c: Command) { + this.gameObjects.sendCommand(c); + } + + protected commandExecutors: CommandExecutors = { + [ServerAnnouncement.type]: (c: ServerAnnouncement) => + (this.announcementText.innerText = c.text), + [PlayerDiedCommand.type]: (c: PlayerDiedCommand) => { + if (OptionsHandler.options.vibrationEnabled) { + navigator.vibrate(150); + } + }, + [UpdateGameState.type]: (c: UpdateGameState) => { + this.declaPlanetCountElement.style.width = (c.declaCount / c.limit) * 50 + '%'; + this.redPlanetCountElement.style.width = (c.redCount / c.limit) * 50 + '%'; + }, + [GameEnd.type]: (c: GameEnd) => { + const team = + c.winningTeam === CharacterTeam.decla + ? 'decla' + : 'red'; + this.announcementText.innerHTML = `Team ${team} won 🎉`; + }, + [UpdateOtherPlayerDirections.type]: this.handleOtherPlayerDirections.bind(this), + [GameStart.type]: this.initialize.bind(this), + }; + + private handleOtherPlayerDirections(command: UpdateOtherPlayerDirections) { + this.arrowElements + .splice(command.otherPlayerDirections.length, this.arrowElements.length) + .forEach((e) => e.parentElement?.removeChild(e)); + + for ( + let i = this.arrowElements.length; + i < command.otherPlayerDirections.length; + i++ + ) { + const element = document.createElement('div'); + this.arrowElements.push(element); + this.overlay.appendChild(element); + } + + this.arrowElements.forEach((e, i) => { + const direction = command.otherPlayerDirections[i].direction; + const team = command.otherPlayerDirections[i].team; + const angle = Math.atan2(direction.y, direction.x); + e.className = 'other-player-arrow ' + team; + + const { width, height } = this.overlay.getBoundingClientRect(); + const aspectRatio = width / height; + const directionRatio = direction.x / direction.y; + + let deltaX: number, deltaY: number; + if (aspectRatio < Math.abs(directionRatio)) { + deltaX = (width / 2) * Math.sign(direction.x); + deltaY = deltaX / directionRatio; + } else { + deltaY = (height / 2) * Math.sign(direction.y); + deltaX = deltaY * directionRatio; + } + + const delta = vec2.fromValues(deltaX, deltaY); + const center = vec2.fromValues(width / 2, height / 2); + const p = vec2.add(center, center, delta); + const arrowPadding = 24; + vec2.set( + p, + clamp(p.x, arrowPadding, width - arrowPadding), + clamp(height - p.y, arrowPadding, height - arrowPadding), + ); + e.style.transform = `translateX(${p.x}px) translateY(${ + p.y + }px) translateX(-50%) translateY(-50%) rotate(${-angle + Math.PI / 2}rad) `; + }); } public async start(): Promise { const noiseTexture = await renderNoise([256, 256], 2, 1); - this.setupCommunication(this.playerDecision.server); - await runAnimation( - this.canvas, - [ - { - ...PlanetShape.descriptor, - shaderCombinationSteps: [0, 1, 2, 3], - }, - { - ...BlobShape.descriptor, - shaderCombinationSteps: [0, 1, 2, 8], - }, - { - ...ColorfulCircle.descriptor, - shaderCombinationSteps: [0, 2, 16], - }, - { - ...CircleLight.descriptor, - shaderCombinationSteps: [0, 1, 2, 4, 8, 16], - }, - { - ...Flashlight.descriptor, - shaderCombinationSteps: [0], - }, - ], - this.gameLoop.bind(this), - { - shadowTraceCount: 16, - paletteSize: settings.palette.length, - //enableStopwatch: true, - }, - { - colorPalette: settings.palette, - enableHighDpiRendering: true, - lightCutoffDistance: settings.lightCutoffDistance, - textures: { - noiseTexture: { - source: noiseTexture, - overrides: { - maxFilter: FilteringOptions.LINEAR, - wrapS: WrapOptions.MIRRORED_REPEAT, - wrapT: WrapOptions.MIRRORED_REPEAT, - }, - }, - }, - }, - ); + this.initialize(); + await startAnimation(this.canvas, this.gameLoop.bind(this), noiseTexture); this.socket.close(); this.overlay.innerHTML = ''; } @@ -237,17 +213,13 @@ export class Game { private gameLoop( renderer: Renderer, - currentTime: DOMHighResTimeStamp, + _: DOMHighResTimeStamp, deltaTime: DOMHighResTimeStamp, ): boolean { + this.resolveStarted(); + deltaTime /= 1000; + this.renderer = renderer; - - if ((this.deadTimeout -= deltaTime / 1000) > 0) { - this.announcementText.innerText = `Reviving in ${Math.floor(this.deadTimeout)}…`; - } else { - this.announcementText.parentElement?.removeChild(this.announcementText); - } - this.gameObjects.stepObjects(deltaTime); this.gameObjects.drawObjects(this.renderer, this.overlay); diff --git a/frontend/src/scripts/join-form-handler.ts b/frontend/src/scripts/join-form-handler.ts index 2fa9ba5..b241864 100644 --- a/frontend/src/scripts/join-form-handler.ts +++ b/frontend/src/scripts/join-form-handler.ts @@ -95,6 +95,9 @@ class ServerChooserOption { private divElement = document.createElement('div'); private inputElement = document.createElement('input'); private labelElement = document.createElement('label'); + private serverNameElement = document.createElement('span'); + private completionElement = document.createElement('span'); + private socket: SocketIOClient.Socket; constructor( @@ -111,7 +114,11 @@ class ServerChooserOption { this.labelElement.htmlFor = url; this.divElement.appendChild(this.inputElement); this.divElement.appendChild(this.labelElement); - this.setPlayerLabelText(); + this.labelElement.appendChild(this.serverNameElement); + this.labelElement.appendChild(document.createElement('br')); + this.labelElement.appendChild(this.completionElement); + this.completionElement.className = 'completion'; + this.setServerInfoLabelText(); this.socket = io(url, { reconnection: false, @@ -121,11 +128,15 @@ class ServerChooserOption { this.socket.on('connect_error', this.destroy.bind(this)); this.socket.on('connect_timeout', this.destroy.bind(this)); this.socket.on('disconnect', this.destroy.bind(this)); - this.socket.emit(TransportEvents.SubscribeForPlayerCount); - this.socket.on(TransportEvents.PlayerCountUpdate, (v: number) => { - this.content.playerCount = v; - this.setPlayerLabelText(); - }); + this.socket.emit(TransportEvents.SubscribeForServerInfoUpdates); + this.socket.on( + TransportEvents.ServerInfoUpdate, + ([playerCount, gameState]: [number, number]) => { + this.content.playerCount = playerCount; + this.content.gameStatePercent = gameState; + this.setServerInfoLabelText(); + }, + ); } public destroy() { @@ -134,8 +145,25 @@ class ServerChooserOption { this.onDestroy(this); } - private setPlayerLabelText() { - this.labelElement.innerText = `${this.content.serverName} - ${this.content.playerCount}/${this.content.playerLimit} players`; + private getRoundCompletionText(percent: number): string { + const texts = [ + 'Just started', + 'Just started', + 'Ongoing', + 'Halfway through', + 'Nearly over', + 'About to finish', + 'Game is over', + ]; + + return texts[Math.floor((percent / 100) * (texts.length - 1))]; + } + + private setServerInfoLabelText() { + this.serverNameElement.innerText = `${this.content.serverName} - ${this.content.playerCount}/${this.content.playerLimit} players`; + this.completionElement.innerText = this.getRoundCompletionText( + this.content.gameStatePercent, + ); } public get element(): HTMLElement { diff --git a/frontend/src/scripts/objects/camera.ts b/frontend/src/scripts/objects/camera.ts index 27d932f..07d78a5 100644 --- a/frontend/src/scripts/objects/camera.ts +++ b/frontend/src/scripts/objects/camera.ts @@ -16,7 +16,7 @@ export class Camera extends GameObject implements ViewObject { public beforeDestroy(): void {} - public step(deltaTimeInMilliseconds: number): void {} + public step(deltaTimeInSeconds: number): void {} public draw(renderer: Renderer, overlay: HTMLElement) { const canvasAspectRatio = renderer.canvasSize.x / renderer.canvasSize.y; diff --git a/frontend/src/scripts/objects/character-view.ts b/frontend/src/scripts/objects/character-view.ts index d065f2d..349fdb5 100644 --- a/frontend/src/scripts/objects/character-view.ts +++ b/frontend/src/scripts/objects/character-view.ts @@ -33,7 +33,7 @@ export class CharacterView extends CharacterBase implements ViewObject { public beforeDestroy(): void {} - public step(deltaTimeInMilliseconds: number): void {} + public step(deltaTimeInSeconds: number): void {} public draw(renderer: Renderer, overlay: HTMLElement): void { this.shape.setCircles([this.head!, this.leftFoot!, this.rightFoot!]); diff --git a/frontend/src/scripts/objects/game-object-container.ts b/frontend/src/scripts/objects/game-object-container.ts index cd076f5..89de287 100644 --- a/frontend/src/scripts/objects/game-object-container.ts +++ b/frontend/src/scripts/objects/game-object-container.ts @@ -5,9 +5,8 @@ import { CreateObjectsCommand, CreatePlayerCommand, DeleteObjectsCommand, - GameObject, Id, - UpdateObjectsCommand, + RemoteCallsForObjects, } from 'shared'; import { Game } from '../game'; import { Camera } from './camera'; @@ -24,8 +23,11 @@ export class GameObjectContainer extends CommandReceiver { if (this.camera) { this.deleteObject(this.camera.id); } + this.player = c.character as PlayerCharacterView; + this.camera = new Camera(this.game); + this.addObject(this.player); this.addObject(this.camera); }, @@ -33,24 +35,25 @@ export class GameObjectContainer extends CommandReceiver { [CreateObjectsCommand.type]: (c: CreateObjectsCommand) => c.objects.forEach((o) => this.addObject(o as ViewObject)), + [RemoteCallsForObjects.type]: (c: RemoteCallsForObjects) => + c.callsForObjects.forEach((c) => + this.objects.get(c.id)?.processRemoteCalls(c.calls), + ), + [DeleteObjectsCommand.type]: (c: DeleteObjectsCommand) => c.ids.forEach((id: Id) => this.deleteObject(id)), - - [UpdateObjectsCommand.type]: (c: UpdateObjectsCommand) => { - c.updates.forEach((u) => this.objects.get(u.id)?.update(u.updates)); - }, }; constructor(private game: Game) { super(); } - public stepObjects(delta: number) { + public stepObjects(deltaTimeInSeconds: number) { if (this.player) { this.camera.center = this.player.position; } - this.objects.forEach((o) => o.step(delta)); + this.objects.forEach((o) => o.step(deltaTimeInSeconds)); } public drawObjects(renderer: Renderer, overlay: HTMLElement) { diff --git a/frontend/src/scripts/objects/lamp-view.ts b/frontend/src/scripts/objects/lamp-view.ts index 2da4521..f41333f 100644 --- a/frontend/src/scripts/objects/lamp-view.ts +++ b/frontend/src/scripts/objects/lamp-view.ts @@ -16,7 +16,7 @@ export class LampView extends LampBase implements ViewObject { this.light = new CircleLight(center, color, lightness); } - public step(deltaTimeInMilliseconds: number): void {} + public step(deltaTimeInSeconds: number): void {} public beforeDestroy(): void {} diff --git a/frontend/src/scripts/objects/planet-view.ts b/frontend/src/scripts/objects/planet-view.ts index d3281c0..a2bc27a 100644 --- a/frontend/src/scripts/objects/planet-view.ts +++ b/frontend/src/scripts/objects/planet-view.ts @@ -5,6 +5,14 @@ import { RenderCommand } from '../commands/types/render'; import { PlanetShape } from '../shapes/planet-shape'; import { ViewObject } from './view-object'; +type FallingPoint = { + velocity: vec2; + position: vec2; + element: HTMLElement; + addedToOverlay: boolean; + timeToLive: number; +}; + export class PlanetView extends PlanetBase implements ViewObject { private shape: PlanetShape; private ownershipProgess: HTMLElement; @@ -22,9 +30,48 @@ export class PlanetView extends PlanetBase implements ViewObject { this.ownershipProgess.className = 'ownership'; } - public step(deltaTimeInMilliseconds: number): void { - this.shape.randomOffset += deltaTimeInMilliseconds / 4000; + public step(deltaTimeInSeconds: number): void { + this.shape.randomOffset += deltaTimeInSeconds / 4; this.shape.colorMixQ = this.ownership; + + this.generatedPointElements.forEach((p) => { + vec2.add( + p.velocity, + p.velocity, + vec2.scale(vec2.create(), vec2.fromValues(0, 50), deltaTimeInSeconds), + ); + + vec2.add( + p.position, + p.position, + vec2.scale(vec2.create(), p.velocity, deltaTimeInSeconds), + ); + + if ((p.timeToLive -= deltaTimeInSeconds) <= 0) { + p.element.parentElement?.removeChild(p.element); + } else { + p.element.style.opacity = Math.min(1, p.timeToLive).toString(); + } + }); + + this.generatedPointElements = this.generatedPointElements.filter( + (p) => p.timeToLive > 0, + ); + } + + private generatedPointElements: Array = []; + + public generatedPoints(value: number) { + const element = document.createElement('div'); + element.className = 'falling-point ' + (this.ownership < 0.5 ? 'decla' : 'red'); + element.innerText = '+' + value; + this.generatedPointElements.push({ + element, + addedToOverlay: false, + timeToLive: Random.getRandomInRange(2, 3), + position: vec2.create(), + velocity: vec2.fromValues(Random.getRandomInRange(-30, 30), 0), + }); } private getGradient(): string { @@ -34,18 +81,21 @@ export class PlanetView extends PlanetBase implements ViewObject { ? `conic-gradient( var(--bright-decla) ${sidePercent}%, var(--bright-decla) ${sidePercent}%, - var(--bright-neutral) ${sidePercent}%, - var(--bright-neutral) 100% + rgba(0, 0, 0, 0) ${sidePercent}%, + rgba(0, 0, 0, 0) 100% )` : `conic-gradient( - var(--bright-neutral) 0%, - var(--bright-neutral) ${100 - sidePercent}%, + rgba(0, 0, 0, 0) 0%, + rgba(0, 0, 0, 0) ${100 - sidePercent}%, var(--bright-red) ${100 - sidePercent}%, var(--bright-red) 100% )`; } public beforeDestroy(): void { this.ownershipProgess.parentElement?.removeChild(this.ownershipProgess); + this.generatedPointElements.forEach((p) => + p.element.parentElement?.removeChild(p.element), + ); } public draw(renderer: Renderer, overlay: HTMLElement): void { @@ -54,6 +104,16 @@ export class PlanetView extends PlanetBase implements ViewObject { } const screenPosition = renderer.worldToDisplayCoordinates(this.center); + + this.generatedPointElements.forEach((p) => { + if (!p.addedToOverlay) { + overlay.appendChild(p.element); + } + + p.element.style.left = screenPosition.x + p.position.x + 'px'; + p.element.style.top = screenPosition.y + p.position.y + 'px'; + }); + this.ownershipProgess.style.left = screenPosition.x + 'px'; this.ownershipProgess.style.top = screenPosition.y + 'px'; this.ownershipProgess.style.background = this.getGradient(); diff --git a/frontend/src/scripts/objects/player-character-view.ts b/frontend/src/scripts/objects/player-character-view.ts index 732fa6e..4560a08 100644 --- a/frontend/src/scripts/objects/player-character-view.ts +++ b/frontend/src/scripts/objects/player-character-view.ts @@ -4,6 +4,7 @@ import { Circle, Id, PlayerCharacterBase, CharacterTeam, settings } from 'shared import { OptionsHandler } from '../options-handler'; import { BlobShape } from '../shapes/blob-shape'; +import { SoundHandler, Sounds } from '../sound-handler'; import { ViewObject } from './view-object'; export class PlayerCharacterView extends PlayerCharacterBase implements ViewObject { @@ -39,8 +40,17 @@ export class PlayerCharacterView extends PlayerCharacterBase implements ViewObje return this.head!.center; } - public step(deltaTimeInMilliseconds: number): void { - this.timeSinceLastNameElementUpdate += deltaTimeInMilliseconds; + public setHealth(health: number) { + const previousHealth = this.health; + super.setHealth(health); + SoundHandler.play( + Sounds.hit, + (0.4 * 2 * (previousHealth - health)) / settings.playerMaxStrength, + ); + } + + public step(deltaTimeInSeconds: number): void { + this.timeSinceLastNameElementUpdate += deltaTimeInSeconds; this.healthElement.style.width = (50 * this.health) / settings.playerMaxHealth + 'px'; this.statsElement.innerText = this.getStatsText(); if (this.previousHealth > this.health) { @@ -52,6 +62,10 @@ export class PlayerCharacterView extends PlayerCharacterBase implements ViewObje this.previousHealth = this.health; } + public onShoot(strength: number) { + SoundHandler.play(Sounds.shoot, (0.3 * 2 * strength) / settings.playerMaxStrength); + } + public beforeDestroy(): void { this.nameElement.parentElement?.removeChild(this.nameElement); } diff --git a/frontend/src/scripts/objects/projectile-view.ts b/frontend/src/scripts/objects/projectile-view.ts index 04e481f..6c3168b 100644 --- a/frontend/src/scripts/objects/projectile-view.ts +++ b/frontend/src/scripts/objects/projectile-view.ts @@ -23,7 +23,9 @@ export class ProjectileView extends ProjectileBase implements ViewObject { ); } - public step(deltaTimeInMilliseconds: number): void { + public step(deltaTimeInSeconds: number): void { + super.step(deltaTimeInSeconds); + this.circle.center = this.center; this.light.center = this.center; this.light.intensity = (0.15 * this.strength) / settings.projectileMaxStrength; diff --git a/frontend/src/scripts/options-handler.ts b/frontend/src/scripts/options-handler.ts index b67b50e..473636e 100644 --- a/frontend/src/scripts/options-handler.ts +++ b/frontend/src/scripts/options-handler.ts @@ -29,11 +29,7 @@ export abstract class OptionsHandler { }; if (this._options.musicEnabled) { - const firstClickListener = () => { - document.removeEventListener('click', firstClickListener); - SoundHandler.playAmbient(); - }; - document.addEventListener('click', firstClickListener); + SoundHandler.playAmbient(); } } diff --git a/frontend/src/scripts/sound-handler.ts b/frontend/src/scripts/sound-handler.ts index 97112ac..bfab026 100644 --- a/frontend/src/scripts/sound-handler.ts +++ b/frontend/src/scripts/sound-handler.ts @@ -8,37 +8,71 @@ export enum Sounds { hit = 'hit', shoot = 'shoot', click = 'click', - ambient = 'ambient', } +const concurrencyScale = 5; + export abstract class SoundHandler { private static sounds: { [key in Sounds]: HTMLAudioElement }; + private static isAmbientPlaying = false; - public static initialize() { + private static ambientSound = new Audio(ambientSound); + + private static initialized = false; + public static async initialize() { this.sounds = { - [Sounds.hit]: new Audio(hitSound), - [Sounds.shoot]: new Audio(shootSound), - [Sounds.click]: new Audio(clickSound), - [Sounds.ambient]: new Audio(ambientSound), + [Sounds.hit]: await this.initializeSound(hitSound), + [Sounds.shoot]: await this.initializeSound(shootSound), + [Sounds.click]: await this.initializeSound(clickSound), }; - this.sounds.ambient.volume = 0.5; + + this.ambientSound.play(); + this.ambientSound.muted = true; + this.initialized = true; + + setTimeout(() => { + this.ambientSound.muted = false; + this.ambientSound.volume = 0.5; + if (!this.isAmbientPlaying) { + this.ambientSound.pause(); + } + }, 100); } - public static play(sound: Sounds) { - if (OptionsHandler.options.soundsEnabled) { - if (this.sounds[sound].currentTime > 0) { - (this.sounds[sound].cloneNode() as HTMLAudioElement).play(); - } else { - this.sounds[sound].play(); - } + private static async initializeSound(hitSound: string): Promise { + const sound = new Audio(hitSound); + sound.muted = true; + await sound.play(); + sound.pause(); + sound.muted = false; + sound.currentTime = 0; + return sound; + } + + public static play(sound: Sounds, volume: number = 1) { + if (!this.initialized || !OptionsHandler.options.soundsEnabled) { + return; } + + const audio = + this.sounds[sound].currentTime > 0 + ? (this.sounds[sound].cloneNode(true) as HTMLAudioElement) + : this.sounds[sound]; + audio.volume = volume; + audio.play(); } public static playAmbient() { - this.sounds.ambient.play(); + this.isAmbientPlaying = true; + if (this.initialized) { + this.ambientSound.play(); + } } public static stopAmbient() { - this.sounds.ambient.pause(); + this.isAmbientPlaying = false; + if (this.initialized) { + this.ambientSound.pause(); + } } } diff --git a/frontend/src/scripts/start-animation.ts b/frontend/src/scripts/start-animation.ts new file mode 100644 index 0000000..c8d6b37 --- /dev/null +++ b/frontend/src/scripts/start-animation.ts @@ -0,0 +1,64 @@ +import { + CircleLight, + ColorfulCircle, + FilteringOptions, + Flashlight, + Renderer, + runAnimation, + WrapOptions, +} from 'sdf-2d'; +import { settings } from 'shared'; +import { BlobShape } from './shapes/blob-shape'; +import { PlanetShape } from './shapes/planet-shape'; + +export const startAnimation = async ( + canvas: HTMLCanvasElement, + draw: (r: Renderer, current: number, delta: number) => boolean, + noiseTexture: TexImageSource, +): Promise => + await runAnimation( + canvas, + [ + { + ...PlanetShape.descriptor, + shaderCombinationSteps: [0, 1, 2, 3], + }, + { + ...BlobShape.descriptor, + shaderCombinationSteps: [0, 1, 2, 8], + }, + { + ...ColorfulCircle.descriptor, + shaderCombinationSteps: [0, 2, 16], + }, + { + ...CircleLight.descriptor, + shaderCombinationSteps: [0, 1, 2, 4, 8, 16], + }, + { + ...Flashlight.descriptor, + shaderCombinationSteps: [0], + }, + ], + draw, + { + shadowTraceCount: 16, + paletteSize: settings.palette.length, + //enableStopwatch: true, + }, + { + colorPalette: settings.palette, + enableHighDpiRendering: true, + lightCutoffDistance: settings.lightCutoffDistance, + textures: { + noiseTexture: { + source: noiseTexture, + overrides: { + maxFilter: FilteringOptions.LINEAR, + wrapS: WrapOptions.MIRRORED_REPEAT, + wrapT: WrapOptions.MIRRORED_REPEAT, + }, + }, + }, + }, + ); diff --git a/frontend/src/styles/_vars.scss b/frontend/src/styles/_vars.scss index dc4cd7b..377d9db 100644 --- a/frontend/src/styles/_vars.scss +++ b/frontend/src/styles/_vars.scss @@ -13,7 +13,7 @@ $large-icon: 48px; $small-icon: 32px; $bright-decla: #4069a5; $bright-red: #d15652; -$bright-neutral: #88888877; +$bright-neutral: #ccc; :root { --bright-decla: #{$bright-decla}; diff --git a/frontend/src/styles/form.scss b/frontend/src/styles/form.scss index 175e13d..b258c84 100644 --- a/frontend/src/styles/form.scss +++ b/frontend/src/styles/form.scss @@ -96,9 +96,15 @@ form { border-radius: $border-radius; padding: $small-padding; border: $border; + cursor: pointer; margin: $border-width-focused - $border-width $border-width-focused - $border-width + $small-padding / 2; + + .completion { + font-size: 0.7em; + color: $bright-neutral; + } } &:focus { diff --git a/frontend/src/styles/settings.scss b/frontend/src/styles/settings.scss index 839bbf4..8f918b0 100644 --- a/frontend/src/styles/settings.scss +++ b/frontend/src/styles/settings.scss @@ -50,13 +50,18 @@ #settings { display: flex; flex-direction: column; - padding: 0 $medium-padding $medium-padding $medium-padding; + @include background; + border-radius: 12px; + z-index: 100; + margin-right: $medium-padding - $small-padding; + padding: $small-padding; transform: translateY(-100%); @media (max-width: $breakpoint) { flex-direction: row-reverse; transform: translateX(100%); - padding: $medium-padding 0 $medium-padding $medium-padding; + margin-top: $medium-padding - $small-padding; + margin-right: 0; } opacity: 0; @@ -72,13 +77,9 @@ svg { cursor: pointer; transition: opacity $animation-time; - margin-bottom: $small-padding; - margin-left: 0; @include square($large-icon); @media (max-width: $breakpoint) { @include square($small-icon); - margin-bottom: 0; - margin-left: $small-padding; } } @@ -130,4 +131,33 @@ } } } + + img, + svg { + margin-top: $small-padding; + @media (max-width: $breakpoint) { + margin-top: 0; + margin-right: $small-padding; + } + } + + label:first-child { + img, + svg { + margin-top: 0; + margin-right: 0; + } + } + + label:not(:first-child) { + input[type='checkbox']:after { + $height: 4px; + top: $height / 2 + $small-padding; + + @media (max-width: $breakpoint) { + right: $small-padding; + top: $height / 2; + } + } + } } diff --git a/frontend/static/flag.svg b/frontend/static/flag.svg new file mode 100644 index 0000000..20653df --- /dev/null +++ b/frontend/static/flag.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/static/shoot2.mp3 b/frontend/static/shoot2.mp3 new file mode 100644 index 0000000..de8c874 Binary files /dev/null and b/frontend/static/shoot2.mp3 differ diff --git a/frontend/static/shoot3.mp3 b/frontend/static/shoot3.mp3 new file mode 100644 index 0000000..89face6 Binary files /dev/null and b/frontend/static/shoot3.mp3 differ diff --git a/frontend/static/shoot4.mp3 b/frontend/static/shoot4.mp3 new file mode 100644 index 0000000..ce505d0 Binary files /dev/null and b/frontend/static/shoot4.mp3 differ diff --git a/frontend/static/shoot5.mp3 b/frontend/static/shoot5.mp3 new file mode 100644 index 0000000..0dfa8b9 Binary files /dev/null and b/frontend/static/shoot5.mp3 differ diff --git a/frontend/static/shoot6.mp3 b/frontend/static/shoot6.mp3 new file mode 100644 index 0000000..2ce159b Binary files /dev/null and b/frontend/static/shoot6.mp3 differ diff --git a/shared/src/commands/types/game-end.ts b/shared/src/commands/types/game-end.ts new file mode 100644 index 0000000..b6c1ca6 --- /dev/null +++ b/shared/src/commands/types/game-end.ts @@ -0,0 +1,18 @@ +import { CharacterTeam } from '../../objects/types/character-team'; +import { serializable } from '../../transport/serialization/serializable'; +import { Command } from '../command'; + +@serializable +export class GameEnd extends Command { + constructor( + public readonly winningTeam: CharacterTeam, + public readonly endCardLengthInSeconds: number, + public readonly shouldReconnect: boolean, + ) { + super(); + } + + public toArray(): Array { + return [this.winningTeam, this.endCardLengthInSeconds, this.shouldReconnect]; + } +} diff --git a/shared/src/commands/types/game-start.ts b/shared/src/commands/types/game-start.ts new file mode 100644 index 0000000..602670e --- /dev/null +++ b/shared/src/commands/types/game-start.ts @@ -0,0 +1,13 @@ +import { serializable } from '../../transport/serialization/serializable'; +import { Command } from '../command'; + +@serializable +export class GameStart extends Command { + constructor() { + super(); + } + + public toArray(): Array { + return []; + } +} diff --git a/shared/src/commands/types/remote-calls-for-object.ts b/shared/src/commands/types/remote-calls-for-object.ts new file mode 100644 index 0000000..f1713df --- /dev/null +++ b/shared/src/commands/types/remote-calls-for-object.ts @@ -0,0 +1,14 @@ +import { serializable } from '../../transport/serialization/serializable'; +import { Command } from '../command'; +import { RemoteCallsForObject } from './remote-calls-for-objects'; + +@serializable +export class RemoteCallsForObjects extends Command { + constructor(public readonly callsForObjects: Array) { + super(); + } + + public toArray(): Array { + return [this.callsForObjects]; + } +} diff --git a/shared/src/commands/types/remote-calls-for-objects.ts b/shared/src/commands/types/remote-calls-for-objects.ts new file mode 100644 index 0000000..f50f0c1 --- /dev/null +++ b/shared/src/commands/types/remote-calls-for-objects.ts @@ -0,0 +1,15 @@ +import { Id } from '../../main'; +import { RemoteCall } from '../../objects/game-object'; +import { serializable } from '../../transport/serialization/serializable'; +import { Command } from '../command'; + +@serializable +export class RemoteCallsForObject extends Command { + constructor(public readonly id: Id, public readonly calls: Array) { + super(); + } + + public toArray(): Array { + return [this.id, this.calls]; + } +} diff --git a/shared/src/commands/types/server-announcement.ts b/shared/src/commands/types/server-announcement.ts new file mode 100644 index 0000000..ae3c32a --- /dev/null +++ b/shared/src/commands/types/server-announcement.ts @@ -0,0 +1,13 @@ +import { serializable } from '../../transport/serialization/serializable'; +import { Command } from '../command'; + +@serializable +export class ServerAnnouncement extends Command { + constructor(public readonly text: string) { + super(); + } + + public toArray(): Array { + return [this.text]; + } +} diff --git a/shared/src/commands/types/update-game-state.ts b/shared/src/commands/types/update-game-state.ts index f9d231e..991a502 100644 --- a/shared/src/commands/types/update-game-state.ts +++ b/shared/src/commands/types/update-game-state.ts @@ -1,37 +1,17 @@ -import { vec2 } from 'gl-matrix'; -import { CharacterTeam } from '../../objects/types/character-team'; import { serializable } from '../../transport/serialization/serializable'; import { Command } from '../command'; -@serializable -export class OtherPlayerDirection { - public constructor( - public readonly direction: vec2, - public readonly team: CharacterTeam, - ) {} - - public toArray(): Array { - return [this.direction, this.team]; - } -} - @serializable export class UpdateGameState extends Command { public constructor( public readonly declaCount: number, public readonly redCount: number, - public readonly neutralCount: number, - public readonly otherPlayerDirections: Array, + public readonly limit: number, ) { super(); } public toArray(): Array { - return [ - this.declaCount, - this.redCount, - this.neutralCount, - this.otherPlayerDirections, - ]; + return [this.declaCount, this.redCount, this.limit]; } } diff --git a/shared/src/commands/types/update-objects.ts b/shared/src/commands/types/update-objects.ts deleted file mode 100644 index 8ff9356..0000000 --- a/shared/src/commands/types/update-objects.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { UpdateObjectMessage } from '../../objects/update-object-message'; -import { serializable } from '../../transport/serialization/serializable'; -import { Command } from '../command'; - -@serializable -export class UpdateObjectsCommand extends Command { - public constructor(public readonly updates: Array) { - super(); - } - - public toArray(): Array { - return [this.updates]; - } -} diff --git a/shared/src/commands/types/update-other-player-directions.ts b/shared/src/commands/types/update-other-player-directions.ts new file mode 100644 index 0000000..b5bc248 --- /dev/null +++ b/shared/src/commands/types/update-other-player-directions.ts @@ -0,0 +1,27 @@ +import { vec2 } from 'gl-matrix'; +import { CharacterTeam } from '../../objects/types/character-team'; +import { serializable } from '../../transport/serialization/serializable'; +import { Command } from '../command'; + +@serializable +export class OtherPlayerDirection { + public constructor( + public readonly direction: vec2, + public readonly team: CharacterTeam, + ) {} + + public toArray(): Array { + return [this.direction, this.team]; + } +} + +@serializable +export class UpdateOtherPlayerDirections extends Command { + public constructor(public readonly otherPlayerDirections: Array) { + super(); + } + + public toArray(): Array { + return [this.otherPlayerDirections]; + } +} diff --git a/shared/src/communication/server-information.ts b/shared/src/communication/server-information.ts index fabbbdb..425ef2a 100644 --- a/shared/src/communication/server-information.ts +++ b/shared/src/communication/server-information.ts @@ -2,6 +2,7 @@ export interface ServerInformation { playerLimit: number; playerCount: number; serverName: string; + gameStatePercent: number; } -export const serverInformationEndpoint = '/stats'; +export const serverInformationEndpoint = '/state'; diff --git a/shared/src/main.ts b/shared/src/main.ts index bcb12f8..9031c73 100644 --- a/shared/src/main.ts +++ b/shared/src/main.ts @@ -5,12 +5,17 @@ export * from './commands/types/delete-objects'; export * from './commands/types/ternary-action'; export * from './commands/types/move-action'; export * from './commands/types/primary-action'; +export * from './commands/types/remote-calls-for-objects'; +export * from './commands/types/remote-calls-for-object'; export * from './commands/types/player-died'; -export * from './commands/types/update-objects'; -export * from './commands/types/update-game-state'; +export * from './commands/types/server-announcement'; +export * from './commands/types/game-end'; +export * from './commands/types/game-start'; +export * from './commands/types/update-other-player-directions'; export * from './commands/types/secondary-action'; -export * from './commands/command-receiver'; export * from './commands/types/update-game-state'; +export * from './commands/command-receiver'; +export * from './commands/types/update-other-player-directions'; export * from './commands/command-executors'; export * from './commands/command-generator'; export * from './commands/broadcast-commands'; @@ -46,7 +51,6 @@ export * from './objects/types/player-character-base'; export * from './objects/types/lamp-base'; export * from './objects/types/planet-base'; export * from './objects/types/projectile-base'; -export * from './objects/update-object-message'; export * from './settings'; export * from './transport/transport-events'; export * from './transport/identity'; diff --git a/shared/src/objects/game-object.ts b/shared/src/objects/game-object.ts index 76f080d..fd8cedf 100644 --- a/shared/src/objects/game-object.ts +++ b/shared/src/objects/game-object.ts @@ -1,14 +1,37 @@ -import { UpdateMessage, UpdateObjectMessage } from '../main'; import { Id } from '../transport/identity'; +import { serializable } from '../transport/serialization/serializable'; -export abstract class GameObject { - constructor(public readonly id: Id) {} +@serializable +export class RemoteCall { + constructor(public readonly functionName: string, public readonly args: Array) {} - public calculateUpdates(): UpdateObjectMessage | undefined { - return; - } - - update(updates: Array): void { - updates.forEach((u) => ((this as any)[u.key] = u.value)); + public toArray(): Array { + return [this.functionName, this.args]; + } +} + +export abstract class GameObject { + private remoteCalls: Array = []; + + constructor(public readonly id: Id) {} + + public processRemoteCalls(remoteCalls: Array) { + remoteCalls.forEach((r) => + ((this[r.functionName as keyof this] as unknown) as ( + ...args: Array + ) => unknown)(...r.args), + ); + } + + public getRemoteCalls(): Array { + return this.remoteCalls; + } + + public resetRemoteCalls() { + this.remoteCalls = []; + } + + protected remoteCall(name: string & keyof this, ...args: Array) { + this.remoteCalls.push(new RemoteCall(name, args)); } } diff --git a/shared/src/objects/types/planet-base.ts b/shared/src/objects/types/planet-base.ts index 2f2b1e0..2fe77f3 100644 --- a/shared/src/objects/types/planet-base.ts +++ b/shared/src/objects/types/planet-base.ts @@ -19,6 +19,12 @@ export class PlanetBase extends GameObject { vec2.scale(this.center, this.center, 1 / vertices.length); } + public setOwnership(value: number) { + this.ownership = value; + } + + public generatedPoints(value: number) {} + public static createPlanetVertices( center: vec2, width: number, diff --git a/shared/src/objects/types/player-character-base.ts b/shared/src/objects/types/player-character-base.ts index 889c0dc..5fec1ad 100644 --- a/shared/src/objects/types/player-character-base.ts +++ b/shared/src/objects/types/player-character-base.ts @@ -20,6 +20,25 @@ export class PlayerCharacterBase extends CharacterBase { super(id, team, health, head, leftFoot, rightFoot); } + public onShoot(strength: number) {} + + public updateCircles(head: Circle, leftFoot: Circle, rightFoot: Circle) { + this.head!.center = head.center; + this.head!.radius = head.radius; + this.leftFoot!.center = leftFoot.center; + this.leftFoot!.radius = leftFoot.radius; + this.rightFoot!.center = rightFoot.center; + this.rightFoot!.radius = rightFoot.radius; + } + + public setHealth(health: number) { + this.health = health; + } + + public setKillCount(killCount: number) { + this.killCount = killCount; + } + public toArray(): Array { return [ this.id, diff --git a/shared/src/objects/types/projectile-base.ts b/shared/src/objects/types/projectile-base.ts index 89d27db..96aa789 100644 --- a/shared/src/objects/types/projectile-base.ts +++ b/shared/src/objects/types/projectile-base.ts @@ -1,4 +1,5 @@ import { vec2 } from 'gl-matrix'; +import { settings } from '../../settings'; import { Id } from '../../transport/identity'; import { serializable } from '../../transport/serialization/serializable'; import { GameObject } from '../game-object'; @@ -16,6 +17,15 @@ export class ProjectileBase extends GameObject { super(id); } + public setCenter(center: vec2) { + this.center = center; + } + + public step(deltaTimeInSeconds: number) { + this.strength -= settings.projectileFadeSpeed * deltaTimeInSeconds; + this.strength = Math.max(0, this.strength); + } + public toArray(): Array { return [this.id, this.center, this.radius, this.team, this.strength]; } diff --git a/shared/src/objects/update-object-message.ts b/shared/src/objects/update-object-message.ts deleted file mode 100644 index 89c57a0..0000000 --- a/shared/src/objects/update-object-message.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Id } from '../main'; -import { serializable } from '../transport/serialization/serializable'; -import { UpdateMessage } from './update-message'; - -@serializable -export class UpdateObjectMessage { - constructor(public id: Id, public updates: Array) {} - - public toArray(): Array { - return [this.id, this.updates]; - } -} diff --git a/shared/src/settings.ts b/shared/src/settings.ts index a6bb3d8..679a51a 100644 --- a/shared/src/settings.ts +++ b/shared/src/settings.ts @@ -21,6 +21,9 @@ export const settings = { objectsOnCircleLength: 0.002, planetEdgeCount: 7, takeControlTimeInSeconds: 4, + loseControlTimeInSeconds: 24, + planetPointGenerationInterval: 1.5, + planetPointGenerationValue: 1, maxGravityDistance: 700, maxGravityQ: 180, planetControlThreshold: 0.2, @@ -28,6 +31,7 @@ export const settings = { maxGravityStrength: 10000, maxAcceleration: 10000, playerMaxStrength: 80, + endGameDeltaScaling: 4, playerDiedTimeout: 5, playerStrengthRegenerationPerSeconds: 40, projectileMaxStrength: 40, @@ -37,7 +41,7 @@ export const settings = { projectileFadeSpeed: 20, projectileCreationInterval: 0.1, playerColorIndexOffset: 3, - backgroundGradient: [rgb255(90, 38, 43), rgb255(0, 0, 0), rgb255(43, 39, 73)], + backgroundGradient: [rgb255(90, 38, 43), rgb255(43, 39, 73)], declaColor, declaPlanetColor, redColor, diff --git a/shared/src/transport/transport-events.ts b/shared/src/transport/transport-events.ts index 8104af6..395a564 100644 --- a/shared/src/transport/transport-events.ts +++ b/shared/src/transport/transport-events.ts @@ -2,8 +2,8 @@ export enum TransportEvents { PlayerJoining = 'PlayerJoining', PlayerToServer = 'PlayerToServer', ServerToPlayer = 'ServerToPlayer', - SubscribeForPlayerCount = 'SubscribeForPlayerCount', - PlayerCountUpdate = 'PlayerCountUpdate', + SubscribeForServerInfoUpdates = 'SubscribeForServerInfoUpdates', + ServerInfoUpdate = 'ServerInfoUpdate', Ping = 'Ping', Pong = 'Pong', }