import { PhysicalContainer } from './physics/containers/physical-container'; import { Server, Socket } from 'socket.io'; import { TransportEvents, deserialize, settings, ServerInformation, PlayerInformation, UpdateGameState, CharacterTeam, GameEndCommand, GameStartCommand, Command, CommandReceiver, CommandExecutors, ServerAnnouncement, } from 'shared'; import { createWorld } from './create-world'; import { DeltaTimeCalculator } from './helper/delta-time-calculator'; import { Options } from './options'; import { PlayerContainer } from './players/player-container'; import { StepCommand } from './commands/step'; import { GeneratePointsCommand } from './commands/generate-points'; import { AnnounceCommand } from './commands/announce'; const gameStateSubscribedRoom = 'gameStateSubscribedRoom'; export class GameServer extends CommandReceiver { private objects!: PhysicalContainer; private players!: PlayerContainer; private deltaTimes!: Array; private deltaTimeCalculator!: DeltaTimeCalculator; private bluePoints = 0; private redPoints = 0; private matchPointAnnounced: Partial> = {}; private isInEndGame = false; private timeScaling = 1; private serverName: string; private playerLimit: number; private initialize() { const previousPlayers = this.players; this.objects = new PhysicalContainer(); createWorld(this.objects); this.objects.initialize(); this.players = new PlayerContainer( this.objects, this.options.playerLimit, this.options.npcCount, ); this.deltaTimeCalculator = new DeltaTimeCalculator(); this.deltaTimes = []; this.bluePoints = 0; this.redPoints = 0; this.matchPointAnnounced = {}; this.isInEndGame = false; this.timeScaling = 1; previousPlayers?.queueCommandForEachClient(new GameStartCommand()); previousPlayers?.sendQueuedCommands(); } protected commandExecutors: CommandExecutors = { [GeneratePointsCommand.type]: this.addPoints.bind(this), [AnnounceCommand.type]: ({ text }: AnnounceCommand) => this.players.queueCommandForEachClient(new ServerAnnouncement(text)), }; constructor( private readonly io: Server, private options: Options, ) { super(); this.serverName = options.name; this.playerLimit = options.playerLimit; this.initialize(); io.on('connection', (socket: Socket) => { socket.on(TransportEvents.PlayerJoining, (playerInfo: PlayerInformation) => { try { const player = this.players.createPlayer(playerInfo, socket); socket.on(TransportEvents.PlayerToServer, (json: string) => { try { const commands: Array = deserialize(json); commands.forEach((c) => player.handleCommand(c)); } catch (e) { console.error('Error while processing command', e); } }); this.sendServerStateUpdate(); socket.on('disconnect', () => { player.destroy(); this.players.deletePlayer(player); this.sendServerStateUpdate(); }); } catch { socket.disconnect(); } }); socket.on(TransportEvents.SubscribeForServerInfoUpdates, () => { socket.join(gameStateSubscribedRoom); }); }); } private timeSinceLastServerStateUpdate = 0; public sendServerStateUpdate() { this.io .to(gameStateSubscribedRoom) .emit(TransportEvents.ServerInfoUpdate, [this.players.count, this.gameProgress]); } public start() { this.handlePhysics(); } private addPoints({ blue, red }: GeneratePointsCommand) { if (this.isInEndGame) { return; } this.bluePoints += blue; this.redPoints += red; if (this.bluePoints >= this.options.scoreLimit) { this.endGame(CharacterTeam.blue); } else if (this.redPoints >= this.options.scoreLimit) { this.endGame(CharacterTeam.red); } else { this.announceMatchPointOnce(CharacterTeam.blue, this.bluePoints); this.announceMatchPointOnce(CharacterTeam.red, this.redPoints); } } private announceMatchPointOnce(team: CharacterTeam, points: number) { if ( !this.matchPointAnnounced[team] && points >= this.options.scoreLimit * settings.matchPointScoreRatio ) { this.matchPointAnnounced[team] = true; this.players.queueCommandForEachClient( new ServerAnnouncement( `Match point — team ${team}!`, ), ); } } private endGame(winningTeam: CharacterTeam) { this.isInEndGame = true; const endTitleLength = 6; this.players.endGame(winningTeam); this.players.queueCommandForEachClient( new GameEndCommand(winningTeam, endTitleLength, true), ); setTimeout(() => this.destroy(), endTitleLength * 1000 * 1.1); } private destroy() { this.initialize(); } private timeSinceLastPointUpdate = 0; private physicsAccumulator = 0; private handlePhysics() { const delta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds({ setAsBase: true }); this.deltaTimes.push(delta); this.handleStats(); if ((this.timeSinceLastServerStateUpdate += delta) > 4) { this.timeSinceLastServerStateUpdate = 0; this.sendServerStateUpdate(); } if ((this.timeSinceLastPointUpdate += delta) > 0.5) { this.timeSinceLastPointUpdate = 0; this.players.queueCommandForEachClient( new UpdateGameState(this.bluePoints, this.redPoints, this.options.scoreLimit), ); } const fixedDelta = settings.targetPhysicsDeltaTimeInSeconds; const maxSubstepsPerFrame = 5; this.physicsAccumulator += delta; let substeps = Math.floor(this.physicsAccumulator / fixedDelta); if (substeps > maxSubstepsPerFrame) { substeps = maxSubstepsPerFrame; this.physicsAccumulator = 0; } else { this.physicsAccumulator -= substeps * fixedDelta; } for (let i = 0; i < substeps; i++) { let scaledDelta = fixedDelta; if (this.isInEndGame) { this.timeScaling *= Math.pow(settings.endGameDeltaScaling, fixedDelta); scaledDelta /= this.timeScaling; } this.objects.handleCommand(new StepCommand(scaledDelta, this)); this.players.step(scaledDelta); } this.players.stepCommunication(delta); this.objects.resetRemoteCalls(); const physicsDelta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds(); setTimeout( this.handlePhysics.bind(this), Math.max(0, fixedDelta - physicsDelta) * 1000, ); } private handleStats() { const framesBetweenDeltaTimeCalculation = 10000; if (this.deltaTimes.length > framesBetweenDeltaTimeCalculation) { this.deltaTimes.sort((a, b) => a - b); console.info( `Median physics time: ${( this.deltaTimes[Math.floor(framesBetweenDeltaTimeCalculation / 2)] * 1000 ).toFixed(2)} ms`, ); console.info( 'Tail times: ', this.deltaTimes.slice(-20).map((v) => `${(v * 1000).toFixed(2)} ms`), ); console.info( `Memory used: ${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`, ); this.deltaTimes = []; } } private get gameProgress(): number { return (Math.max(this.bluePoints, this.redPoints) / this.options.scoreLimit) * 100; } public get serverInfo(): ServerInformation { return { serverName: this.serverName, playerCount: this.players.count, playerLimit: this.playerLimit, gameStatePercent: this.gameProgress, }; } }