decla-red/backend/src/players/player.ts
2026-08-22 20:59:46 +01:00

362 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { performance } from 'perf_hooks';
import { vec2 } from 'gl-matrix';
import {
CommandExecutors,
CreateObjectsCommand,
CreatePlayerCommand,
DeleteObjectsCommand,
MoveActionCommand,
serialize,
TransportEvents,
SetAspectRatioActionCommand,
calculateViewArea,
settings,
PlayerInformation,
CharacterTeam,
UpdateMinimap,
GameObject,
Command,
MinimapPlayer,
RemoteCallsForObject,
RemoteCallsForObjects,
ServerAnnouncement,
PropertyUpdatesForObjects,
PropertyUpdatesForObject,
PrimaryActionCommand,
LeapActionCommand,
InputAcknowledgement,
ClientHeartbeatCommand,
finiteInRange,
finiteVec2,
isFiniteNumber,
} from 'shared';
import { Socket } from 'socket.io';
import { BoundingBox } from '../physics/bounding-boxes/bounding-box';
import { PhysicalContainer } from '../physics/containers/physical-container';
import { PlayerContainer } from './player-container';
import { PlayerBase } from './player-base';
import { CharacterPhysical } from '../objects/character-physical';
const pingIntervalSeconds = 1;
const minimumAspectRatio = 0.2;
const maximumAspectRatio = 8;
const maximumBufferedBytes = settings.maxBufferedBytesPerClient;
export class Player extends PlayerBase {
private aspectRatio: number = 16 / 9;
private timeSinceLastMessage = 0;
// A Set: the per-snapshot diff is membership-heavy, and insertion-order
// iteration keeps the streamed object order unchanged.
private objectsInViewArea = new Set<GameObject>();
private lastInputClientTimeMs = 0;
private lastInputReceiptMs = 0;
private lastLeapClientTimeMs = 0;
public reconnectToken = '';
// Drives projectile lag compensation as well as the server-side latency stats.
public rttMs = 0;
private timeSinceLastPing = 0;
private lastPingSentMs = 0;
// Nonce of the ping awaiting a reply: prevents a client forging Pongs or
// answering a stale ping to corrupt the RTT that feeds lag compensation.
private pendingPingNonce = 0;
private nextPingNonce = 1;
// Input below is untrusted: every command arrives from `deserialize`
// (a bare JSON.parse, which accepts Infinity as `1e999`). A single
// non-finite value reaching the simulation is unrecoverable — it spreads
// through the shared SDF and leaves a character that never dies or respawns.
protected commandExecutors: CommandExecutors = {
[SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) => {
this.aspectRatio = finiteInRange(
v.aspectRatio,
minimumAspectRatio,
maximumAspectRatio,
this.aspectRatio,
);
},
[MoveActionCommand.type]: (c: MoveActionCommand) => {
const direction = finiteVec2(c.direction, 1);
if (!direction) {
return;
}
this.observeClientTime(c.clientTimeMs);
this.character?.handleMovementAction(new MoveActionCommand(direction, 0));
},
[PrimaryActionCommand.type]: (c: PrimaryActionCommand) => {
const position = finiteVec2(c.position, settings.maxClientPositionMagnitude);
if (!position) {
return;
}
this.observeClientTime(c.clientTimeMs);
this.character?.shootTowards(position, finiteInRange(c.charge, 0, 1, 0));
},
[LeapActionCommand.type]: (c: LeapActionCommand) => {
if (!isFiniteNumber(c.clientTimeMs)) {
return;
}
// Record receipt whether or not leap() accepts it: the predictor must
// stop replaying this leap either way.
this.lastLeapClientTimeMs = c.clientTimeMs;
this.observeClientTime(c.clientTimeMs);
this.character?.leap();
},
// Advances the acknowledgement at the client's send rate regardless of
// whether input changed — otherwise a held key freezes the predictor's
// replay window.
[ClientHeartbeatCommand.type]: (c: ClientHeartbeatCommand) =>
this.observeClientTime(c.clientTimeMs),
};
// Tracks both the newest applied client-clock instant and the server-clock
// instant it arrived. The predictor needs both: which inputs are already in
// the snapshot, and how stale that answer was (see InputAcknowledgement).
private observeClientTime(clientTimeMs: number) {
if (isFiniteNumber(clientTimeMs) && clientTimeMs > this.lastInputClientTimeMs) {
this.lastInputClientTimeMs = clientTimeMs;
this.lastInputReceiptMs = performance.now();
}
}
constructor(
playerInfo: PlayerInformation,
playerContainer: PlayerContainer,
objectContainer: PhysicalContainer,
team: CharacterTeam,
private readonly socket: Socket,
) {
super(playerInfo, playerContainer, objectContainer, team);
this.createCharacter();
this.step(0);
// Only one ping is ever in flight, so RTT is simply now − send-time.
this.socket.on(TransportEvents.Pong, this.onPong);
}
// Only the outstanding ping's nonce counts, once. A client can sit on the
// live ping to inflate measured RTT, so the result is clamped; the next ping
// retires the nonce, bounding any stall at one ping interval.
private readonly onPong = (nonce: unknown) => {
if (this.pendingPingNonce === 0 || nonce !== this.pendingPingNonce) {
return;
}
this.pendingPingNonce = 0;
this.rttMs = Math.min(
performance.now() - this.lastPingSentMs,
settings.maxMeasuredRttMs,
);
};
// The Pong listener outlives the round: the socket survives a restart, so a
// retired Player would stay reachable through it unless removed explicitly.
public detachFromSocket() {
this.socket.off(TransportEvents.Pong, this.onPong);
}
protected createCharacter() {
super.createCharacter();
this.objectsInViewArea.add(this.character!);
this.queueCommandSend(new CreatePlayerCommand(this.character!));
}
private winnerTeam?: CharacterTeam;
public onGameEnded(winnerTeam: CharacterTeam) {
this.winnerTeam = winnerTeam;
}
// Retained so an in-flight shot can still credit a kill to this corpse:
// killCount is re-read at respawn, not snapshotted when the body died.
private dyingCharacter?: CharacterPhysical | null;
public step(deltaTimeInSeconds: number) {
this.stepLifecycle(deltaTimeInSeconds);
}
protected onCharacterDied(character: CharacterPhysical) {
this.dyingCharacter = character;
}
protected onBeforeRespawn() {
if (this.dyingCharacter) {
this.sumKills = Math.max(this.sumKills, this.dyingCharacter.killCount);
this.dyingCharacter = null;
}
}
private handleViewAreaUpdate() {
const viewArea = calculateViewArea(this.center, this.aspectRatio, 1.2);
const bb = new BoundingBox();
bb.topLeft = viewArea.topLeft;
bb.size = viewArea.size;
const inViewArea = new Set(
this.objectContainer.findIntersecting(bb).map((o) => o.gameObject),
);
// The owning character must always be in its own snapshot so the client
// predictor never loses its authoritative anchor.
if (this.character) {
inViewArea.add(this.character);
}
// Set membership rather than Array.includes: this diff runs per player per
// snapshot over everything on screen and was quadratic in that.
const newlyIntersecting = [...inViewArea].filter(
(o) => !this.objectsInViewArea.has(o),
);
const noLongerIntersecting = [...this.objectsInViewArea].filter(
(o) => !inViewArea.has(o),
);
this.objectsInViewArea = inViewArea;
if (noLongerIntersecting.length > 0) {
this.queueCommandSend(
new DeleteObjectsCommand(noLongerIntersecting.map((g) => g.id)),
);
}
if (newlyIntersecting.length > 0) {
this.queueCommandSend(new CreateObjectsCommand(newlyIntersecting));
}
this.queueCommandSend(new UpdateMinimap(this.getMinimapPlayers()));
const propertyUpdates: Array<PropertyUpdatesForObject> = [];
for (const object of this.objectsInViewArea) {
const update = object.getPropertyUpdatesForFrame();
if (update) {
propertyUpdates.push(update);
}
}
this.queueCommandSend(
new PropertyUpdatesForObjects(propertyUpdates, performance.now() / 1000),
);
// Tells the client how much of its own input is reflected in the snapshot
// so its predictor can replay the rest. Only while alive.
if (this.character) {
this.queueCommandSend(
new InputAcknowledgement(
this.lastInputClientTimeMs,
this.character.movementSnapshot,
this.lastLeapClientTimeMs,
this.lastInputReceiptMs > 0
? Math.max(0, performance.now() - this.lastInputReceiptMs)
: 0,
),
);
}
}
// Every living player except this one, by absolute world position for the
// minimap.
private getMinimapPlayers(): Array<MinimapPlayer> {
return this.playerContainer.players
.filter((p) => p !== this && p.character?.isAlive)
.map(
(p) =>
new MinimapPlayer(p.character!.id, vec2.clone(p.character!.center), p.team),
);
}
private commandsToBeSent: Array<Command> = [];
public queueCommandSend(command: Command) {
this.commandsToBeSent.push(command);
}
public stepCommunications(deltaTime: number) {
// Test first and only build the command when there is something to send.
let remoteCalls: Array<RemoteCallsForObject> | undefined;
for (const object of this.objectsInViewArea) {
const calls = object.getRemoteCalls();
if (calls.length > 0) {
(remoteCalls ??= []).push(new RemoteCallsForObject(object.id, calls));
}
}
if (remoteCalls) {
this.queueCommandSend(new RemoteCallsForObjects(remoteCalls));
}
if ((this.timeSinceLastPing += deltaTime) > pingIntervalSeconds) {
this.timeSinceLastPing = 0;
this.lastPingSentMs = performance.now();
this.pendingPingNonce = this.nextPingNonce++;
this.socket.emit(TransportEvents.Ping, this.pendingPingNonce);
}
if ((this.timeSinceLastMessage += deltaTime) > settings.updateMessageInterval) {
this.handleAnnouncements();
this.handleViewAreaUpdate();
this.sendQueuedCommandsToClient();
this.timeSinceLastMessage = 0;
}
}
// engine.io's Socket exposes no bufferedAmount — it lives on the underlying
// ws socket, and only the websocket transport has one.
private get bufferedBytes(): number {
const conn = this.socket.conn as
| { transport?: { socket?: { bufferedAmount?: unknown } } }
| undefined;
const buffered = conn?.transport?.socket?.bufferedAmount;
return typeof buffered === 'number' ? buffered : 0;
}
public sendQueuedCommandsToClient() {
if (this.commandsToBeSent.length === 0) {
return;
}
// Only state next tick regenerates in full may be dropped. Create/delete
// are one-shot: handleViewAreaUpdate has ALREADY advanced
// objectsPreviouslyInViewArea, so dropping them leaves this client
// permanently missing objects and holding ghosts. They go out even backed up.
if (this.bufferedBytes > maximumBufferedBytes) {
// Matched by command type, not `instanceof`: a class identity from a
// bundled shared module is not something to depend on.
const sheddable: ReadonlyArray<string> = [
PropertyUpdatesForObjects.type,
UpdateMinimap.type,
// Regenerated in full next tick — shed with the pose, never advance the
// replay anchor past a snapshot the client never received.
InputAcknowledgement.type,
];
this.commandsToBeSent = this.commandsToBeSent.filter(
(c) => !sheddable.includes(c.type),
);
if (this.commandsToBeSent.length === 0) {
return;
}
}
this.socket.emit(TransportEvents.ServerToPlayer, serialize(this.commandsToBeSent));
this.commandsToBeSent = [];
}
public get scoreSnapshot(): { kills: number; deaths: number } {
return {
kills: Math.max(this.sumKills, this.character?.killCount ?? 0),
deaths: this.sumDeaths,
};
}
private handleAnnouncements() {
let announcement = '';
if (this.winnerTeam) {
announcement = `Team <span class="${this.winnerTeam}">${this.winnerTeam}</span> won 🎉`;
} else if (!this.character) {
announcement = `Reviving in ${Math.round(this.timeUntilRespawn)}…`;
}
if (announcement) {
this.queueCommandSend(new ServerAnnouncement(announcement));
}
}
}