import { vec2 } from 'gl-matrix'; import { CircleLight, FilteringOptions, Renderer, renderNoise, runAnimation, WrapOptions, } from 'sdf-2d'; import { deserialize, TransportEvents, SetAspectRatioActionCommand, UpdateOtherPlayerDirections, clamp, UpdateGameState, GameEndCommand, ServerAnnouncement, GameStartCommand, CommandReceiver, CommandExecutors, Command, settings, } 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 { RenderCommand } from './commands/types/render'; import { StepCommand } from './commands/types/step'; import { Tutorial } from './tutorial'; import { Scoreboard } from './scoreboard'; export class Game extends CommandReceiver { public gameObjects = new GameObjectContainer(this); public renderer?: Renderer; private socket!: Socket; private isBetweenGames = false; public started: Promise; private resolveStarted!: () => unknown; private keyboardListener: KeyboardListener; private mouseListener: MouseListener; private touchListener: TouchListener; private scoreboard = new Scoreboard(); private announcementText = document.createElement('h2'); private arrows: { [id: number]: HTMLElement } = {}; private socketReceiver!: CommandSocket; private tutorial!: Tutorial; 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'; this.keyboardListener = new KeyboardListener(); this.mouseListener = new MouseListener(this.canvas, this); this.touchListener = new TouchListener(this.canvas, this.overlay, this); } private initialize() { this.isBetweenGames = true; this.socket?.close(); this.gameObjects = new GameObjectContainer(this); this.overlay.innerHTML = ''; this.arrows = {}; this.isEnding = false; this.lastAnnouncementText = ''; this.overlay.appendChild(this.scoreboard.element); this.announcementText.innerText = ''; this.timeScaling = 1; this.overlay.appendChild(this.announcementText); this.tutorial = new Tutorial(this.overlay); this.socket = io(this.playerDecision.server, { reconnectionDelayMax: 10000, transports: ['websocket'], forceNew: true, parser, } as any); // In socket.io-client v4 reconnection events are emitted by the Manager // (`socket.io`), not the Socket itself. this.socket.io.on('reconnect_attempt', () => { this.socket.io.opts.transports = ['polling', 'websocket']; }); this.socket.on('disconnect', () => { if (!this.isBetweenGames) { this.destroy(); } }); this.socket.on(TransportEvents.Ping, () => { this.socket.emit(TransportEvents.Pong); }); this.socket.on(TransportEvents.ServerToPlayer, (serializedCommands: string) => { const commands: Array = deserialize(serializedCommands); commands.forEach((c) => this.handleCommand(c)); }); this.socketReceiver = new CommandSocket(this.socket); // The tutorial listens to the same input streams as the socket, so its // stages clear off the player's own commands without any server involvement. this.keyboardListener.clearSubscribers(); this.keyboardListener.subscribe(this.socketReceiver); this.keyboardListener.subscribe(this.tutorial); this.mouseListener.clearSubscribers(); this.mouseListener.subscribe(this.socketReceiver); this.mouseListener.subscribe(this.tutorial); this.touchListener.clearSubscribers(); this.touchListener.subscribe(this.socketReceiver); this.touchListener.subscribe(this.tutorial); this.isBetweenGames = false; this.socket.emit(TransportEvents.PlayerJoining, this.playerDecision); } protected defaultCommandExecutor(c: Command) { this.gameObjects.handleCommand(c); } private lastGameState?: UpdateGameState; private isEnding = false; private timeScaling = 1; private lastAnnouncementText = ''; protected commandExecutors: CommandExecutors = { [ServerAnnouncement.type]: (c: ServerAnnouncement) => { this.lastAnnouncementText = c.text; this.timeSinceLastAnnouncement = 0; }, [UpdateGameState.type]: (c: UpdateGameState) => (this.lastGameState = c), [GameEndCommand.type]: () => (this.isEnding = true), [UpdateOtherPlayerDirections.type]: (c: UpdateOtherPlayerDirections) => (this.lastOtherPlayerDirections = c), [GameStartCommand.type]: this.initialize.bind(this), }; private lastOtherPlayerDirections?: UpdateOtherPlayerDirections; private handleOtherPlayerDirections(command: UpdateOtherPlayerDirections) { command.otherPlayerDirections.forEach((d) => { if (!(d.id! in this.arrows)) { const element = document.createElement('div'); this.arrows[d.id!] = element; this.overlay.appendChild(element); } const e = this.arrows[d.id!]; const direction = d.direction; const team = d.team; const angle = Math.atan2(direction.y, direction.x); e.className = 'other-player-arrow ' + team; if (!this.renderer) { return; } const width = this.renderer.canvasSize.x; const height = this.renderer.canvasSize.y; 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) `; }); for (const id in this.arrows) { if ( Object.prototype.hasOwnProperty.call(this.arrows, id) && command.otherPlayerDirections.find((v) => v.id?.toString() === id) === undefined ) { this.arrows[id].parentElement?.removeChild(this.arrows[id]); delete this.arrows[id]; } } } public async start(): Promise { const noiseTexture = await renderNoise([256, 256], 2, 1); this.initialize(); 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.overlay.innerHTML = ''; this.keyboardListener.destroy(); this.mouseListener.destroy(); this.touchListener.destroy(); } public displayToWorldCoordinates(p: vec2): vec2 { return this.renderer?.displayToWorldCoordinates(p) ?? vec2.create(); } public aspectRatioChanged(aspectRatio: number) { this.socketReceiver.handleCommand(new SetAspectRatioActionCommand(aspectRatio)); } private isActive = true; public destroy() { this.isActive = false; } private timeSinceLastAnnouncement = 0; private framesSinceLastLayoutUpdate = 0; private gameLoop( renderer: Renderer, _: DOMHighResTimeStamp, deltaTime: DOMHighResTimeStamp, ): boolean { this.resolveStarted(); deltaTime /= 1000; let shouldChangeLayout = false; if (++this.framesSinceLastLayoutUpdate > 1) { shouldChangeLayout = true; 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.renderer = renderer; this.gameObjects.handleCommand(new StepCommand(deltaTime)); this.gameObjects.handleCommand( new RenderCommand(this.renderer, this.overlay, shouldChangeLayout), ); this.touchListener.update(deltaTime); this.tutorial.step(this.gameObjects); this.socketReceiver.sendQueuedCommands(); return this.isActive; } private draw() { if (this.lastGameState) { // The local player's team is read off the main character once it exists. this.scoreboard.update(this.lastGameState, this.gameObjects.player?.team); } if (this.lastOtherPlayerDirections) { this.handleOtherPlayerDirections(this.lastOtherPlayerDirections); } this.announcementText.innerHTML = this.lastAnnouncementText; } }