decla-red/frontend/src/scripts/game.ts
2026-08-23 10:27:55 +01:00

410 lines
13 KiB
TypeScript

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<void>;
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<Command> = 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<void> {
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)`;
}
}