import { vec2 } from 'gl-matrix'; import { CircleLight, FilteringOptions, Renderer, renderNoise, runAnimation, WrapOptions, } from 'sdf-2d'; import { deserialize, TransportEvents, SetAspectRatioActionCommand, UpdateMinimap, UpdateGameState, GameEndCommand, ServerAnnouncement, GameStartCommand, CommandReceiver, CommandExecutors, Command, settings, InputAcknowledgement, JoinRejectionReason, } from 'shared'; import { io, Socket } from 'socket.io-client'; import { KeyboardListener } from './commands/keyboard-listener'; import { MouseListener } from './commands/mouse-listener'; import { TouchListener } from './commands/touch-listener'; import { CommandSocket } from './commands/command-socket'; import { PlayerDecision } from './join-form-handler'; import { GameObjectContainer } from './objects/game-object-container'; import parser from 'socket.io-msgpack-parser'; import { CharacterShape } from './shapes/character-shape'; import { PlanetShape } from './shapes/planet-shape'; import { serverTimeline } from './helper/server-timeline'; import { localCharacterPredictor, setFrameTimeMs, } from './helper/prediction/local-character-predictor'; import { Tutorial } from './tutorial'; import { Scoreboard } from './scoreboard'; import { Minimap } from './minimap'; import { ScreenShake } from './screen-shake'; import { FeedbackHud } from './feedback-hud'; const maximumReconnectionAttempts = 10; export class Game extends CommandReceiver { public gameObjects = new GameObjectContainer(this); public renderer?: Renderer; public rejectionReason?: JoinRejectionReason; public readonly started: Promise; private socket!: Socket; private socketReceiver!: CommandSocket; private tutorial!: Tutorial; private resolveStarted!: () => void; private isBetweenGames = false; private isActive = true; private isEnding = false; private timeScaling = 1; private readonly keyboardListener: KeyboardListener; private readonly mouseListener: MouseListener; private readonly touchListener: TouchListener; private readonly scoreboard = new Scoreboard(); private readonly minimap = new Minimap(); private readonly announcementText = document.createElement('h2'); private keystoneArrow?: HTMLElement; private connectionBanner?: HTMLElement; private reconnectToken?: string; private lastAspectRatio?: number; private lastGameState?: UpdateGameState; private lastMinimap?: UpdateMinimap; private lastAnnouncementText = ''; private timeSinceLastAnnouncement = 0; private framesSinceLastLayoutUpdate = 0; protected commandExecutors: CommandExecutors = { [ServerAnnouncement.type]: (c: ServerAnnouncement) => { this.lastAnnouncementText = c.text; this.timeSinceLastAnnouncement = 0; }, [UpdateGameState.type]: (c: UpdateGameState) => (this.lastGameState = c), [InputAcknowledgement.type]: (c: InputAcknowledgement) => localCharacterPredictor.acknowledge( c.clientTimeMs, c.movement, c.lastLeapClientTimeMs, c.ackAgeMs, ), [GameEndCommand.type]: () => (this.isEnding = true), [UpdateMinimap.type]: (c: UpdateMinimap) => (this.lastMinimap = c), [GameStartCommand.type]: () => this.initialize(), }; 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 onInput = (c: Command) => { this.socketReceiver.queue(c); this.tutorial.handleCommand(c); }; this.keyboardListener = new KeyboardListener(onInput); this.mouseListener = new MouseListener(this.canvas, this, onInput); this.touchListener = new TouchListener(this.canvas, this.overlay, this, onInput); } protected defaultCommandExecutor(c: Command) { this.gameObjects.handleCommand(c); } private initialize() { this.isBetweenGames = true; this.socket?.close(); serverTimeline.reset(); localCharacterPredictor.reset(); ScreenShake.reset(); this.gameObjects.reset(); this.gameObjects = new GameObjectContainer(this); this.overlay.innerHTML = ''; this.keystoneArrow = undefined; this.connectionBanner = undefined; this.lastMinimap = undefined; this.isEnding = false; this.lastAnnouncementText = ''; this.announcementText.innerText = ''; this.timeScaling = 1; this.overlay.append( this.scoreboard.element, this.minimap.element, this.announcementText, ); this.tutorial = new Tutorial(this.overlay); this.socket = io(this.playerDecision.server, { reconnectionDelayMax: 10000, // Must be finite, otherwise `reconnect_failed` never fires. reconnectionAttempts: maximumReconnectionAttempts, transports: ['websocket'], forceNew: true, parser, } as any); this.socketReceiver = new CommandSocket(this.socket); this.socket.io.on('reconnect_attempt', () => { this.socket.io.opts.transports = ['polling', 'websocket']; }); this.socket.on('disconnect', () => { if (!this.isBetweenGames) { this.showConnectionBanner('Reconnecting…'); } }); // Fires for every (re)connection: a reconnect is a brand-new server-side player. this.socket.on('connect', () => { if (this.isBetweenGames) { return; } this.hideConnectionBanner(); serverTimeline.reset(); localCharacterPredictor.reset(); this.gameObjects.reset(); this.socket.emit(TransportEvents.PlayerJoining, { ...this.playerDecision, reconnectToken: this.reconnectToken, }); if (this.lastAspectRatio !== undefined) { this.socketReceiver.queue(new SetAspectRatioActionCommand(this.lastAspectRatio)); } }); this.socket.io.on('reconnect_failed', () => { this.showConnectionBanner('Connection lost'); this.destroy(); }); this.socket.on(TransportEvents.PlayerJoined, (token: string) => { this.reconnectToken = typeof token === 'string' ? token : undefined; this.hideConnectionBanner(); }); this.socket.on(TransportEvents.JoinRejected, (reason: JoinRejectionReason) => { this.rejectionReason = reason; this.showConnectionBanner(Game.rejectionText(reason)); this.destroy(); }); this.socket.on(TransportEvents.Ping, (nonce: unknown) => { this.socket.emit(TransportEvents.Pong, nonce); }); this.socket.on(TransportEvents.ServerToPlayer, (serializedCommands: string) => { try { const commands: Array = deserialize(serializedCommands); commands.forEach((c) => { try { this.handleCommand(c); } catch (e) { console.error('Failed to apply a server command', e); } }); } catch (e) { console.error('Dropped an undecodable server message', e); } }); this.isBetweenGames = false; } public async start(): Promise { this.initialize(); const noiseTexture = await renderNoise([256, 256], 2, 1); await runAnimation( this.canvas, [ PlanetShape.descriptor, CharacterShape.descriptor, { ...CircleLight.descriptor, shaderCombinationSteps: [0, 1, 2, 4, 8, 16], }, ], this.gameLoop.bind(this), { shadowTraceCount: 16, paletteSize: settings.paletteDim.length, colorPalette: settings.paletteDim, enableHighDpiRendering: true, lightCutoffDistance: settings.lightCutoffDistance, lightOverlapReduction: settings.lightOverlapReduction, textures: { noiseTexture: { source: noiseTexture, overrides: { maxFilter: FilteringOptions.LINEAR, wrapS: WrapOptions.MIRRORED_REPEAT, wrapT: WrapOptions.MIRRORED_REPEAT, }, }, }, }, ); this.socket.close(); this.gameObjects.reset(); this.overlay.innerHTML = ''; this.hideConnectionBanner(); FeedbackHud.reset(); this.keyboardListener.destroy(); this.mouseListener.destroy(); this.touchListener.destroy(); } public static rejectionText(reason: JoinRejectionReason): string { switch (reason) { case JoinRejectionReason.ServerFull: return 'That server is full — pick another'; case JoinRejectionReason.RoundEnding: return 'That round is just finishing — try again in a moment'; case JoinRejectionReason.AlreadyJoined: return 'Already joined on this connection'; default: return 'The server refused the connection'; } } private showConnectionBanner(text: string) { if (!this.connectionBanner) { this.connectionBanner = document.createElement('div'); this.connectionBanner.className = 'connection-banner'; this.overlay.appendChild(this.connectionBanner); } this.connectionBanner.innerText = text; this.connectionBanner.style.display = 'block'; } private hideConnectionBanner() { if (this.connectionBanner) { this.connectionBanner.style.display = 'none'; } } public displayToWorldCoordinates(p: vec2): vec2 { return this.renderer?.displayToWorldCoordinates(p) ?? vec2.create(); } public aspectRatioChanged(aspectRatio: number) { this.lastAspectRatio = aspectRatio; this.socketReceiver.queue(new SetAspectRatioActionCommand(aspectRatio)); } public destroy() { this.isActive = false; } private gameLoop( renderer: Renderer, currentTime: DOMHighResTimeStamp, deltaTime: DOMHighResTimeStamp, ): boolean { this.renderer = renderer; this.resolveStarted(); // The frame timestamp, not performance.now(): dispatch jitter would land in the replay window. setFrameTimeMs(currentTime); deltaTime /= 1000; // Both run on wall-clock time: the end-game slow motion is already baked into the snapshots. ScreenShake.step(deltaTime); serverTimeline.step(deltaTime); const shouldChangeLayout = ++this.framesSinceLastLayoutUpdate > 1; if (shouldChangeLayout) { this.framesSinceLastLayoutUpdate = 0; this.draw(); } if ( (this.timeSinceLastAnnouncement += deltaTime) > settings.announcementVisibleSeconds ) { this.lastAnnouncementText = ''; } if (this.isEnding) { this.timeScaling *= Math.pow(settings.endGameDeltaScaling, deltaTime); deltaTime /= this.timeScaling; } this.gameObjects.step(deltaTime); this.gameObjects.render(renderer, this.overlay, shouldChangeLayout); this.touchListener.update(); this.tutorial.step(this.gameObjects); this.socketReceiver.sendQueuedCommands(); return this.isActive; } private draw() { if (this.lastGameState) { this.scoreboard.update(this.lastGameState, this.gameObjects.localPlayer?.team); } this.minimap.update( this.gameObjects.localPlayer?.position, this.lastMinimap?.players ?? [], ); this.handleKeystoneArrow(); if (this.announcementText.innerHTML !== this.lastAnnouncementText) { this.announcementText.innerHTML = this.lastAnnouncementText; } } private handleKeystoneArrow() { const keystone = this.gameObjects.planets.find((p) => p.isKeystone); if (!this.renderer || !keystone) { if (this.keystoneArrow) { this.keystoneArrow.style.display = 'none'; } return; } if (!this.keystoneArrow) { this.keystoneArrow = document.createElement('div'); this.overlay.appendChild(this.keystoneArrow); } const [width, height] = this.renderer.canvasSize; const display = this.renderer.worldToDisplayCoordinates(keystone.center); const margin = 48; const onScreen = display[0] >= margin && display[0] <= width - margin && display[1] >= margin && display[1] <= height - margin; this.keystoneArrow.className = 'keystone-arrow ' + keystone.team; this.keystoneArrow.style.display = onScreen ? 'none' : 'block'; if (onScreen) { return; } const dx = display[0] - width / 2; const dy = display[1] - height / 2; const angle = Math.atan2(dy, dx); let deltaX: number, deltaY: number; if (width / height < Math.abs(dx / dy)) { deltaX = (width / 2 - margin) * Math.sign(dx); deltaY = (deltaX * dy) / dx; } else { deltaY = (height / 2 - margin) * Math.sign(dy); deltaX = (deltaY * dx) / dy; } this.keystoneArrow.style.transform = `translateX(${width / 2 + deltaX}px) translateY(${height / 2 + deltaY}px) translateX(-50%) translateY(-50%) rotate(${angle + Math.PI / 2}rad)`; } }