Improve netcode and simplify #4
41 changed files with 2086 additions and 205 deletions
Claude fixes
commit
1295828ac8
|
|
@ -1,8 +1,10 @@
|
|||
import { Options } from './options';
|
||||
|
||||
const isDevelopment = process.env.NODE_ENV !== 'production';
|
||||
|
||||
export const defaultOptions: Options = {
|
||||
port: 3000,
|
||||
name: 'Test server',
|
||||
name: isDevelopment ? 'Dev server' : 'Doppler server',
|
||||
playerLimit: 16,
|
||||
npcCount: 8,
|
||||
seed: Math.random(),
|
||||
|
|
|
|||
|
|
@ -14,17 +14,27 @@ import {
|
|||
CommandReceiver,
|
||||
CommandExecutors,
|
||||
ServerAnnouncement,
|
||||
JoinRejectionReason,
|
||||
beginPropertyUpdateGeneration,
|
||||
} 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 { ServerFullError } from './players/server-full-error';
|
||||
import { Player } from './players/player';
|
||||
import { StepCommand } from './commands/step';
|
||||
import { GeneratePointsCommand } from './commands/generate-points';
|
||||
import { AnnounceCommand } from './commands/announce';
|
||||
|
||||
const gameStateSubscribedRoom = 'gameStateSubscribedRoom';
|
||||
|
||||
interface JoinedSocket {
|
||||
player: Player;
|
||||
onPlayerToServer: (json: string) => void;
|
||||
onDisconnect: () => void;
|
||||
}
|
||||
|
||||
export class GameServer extends CommandReceiver {
|
||||
private objects!: PhysicalContainer;
|
||||
private players!: PlayerContainer;
|
||||
|
|
@ -43,6 +53,7 @@ export class GameServer extends CommandReceiver {
|
|||
|
||||
private initialize() {
|
||||
const previousPlayers = this.players;
|
||||
this.releaseJoinedSockets();
|
||||
this.objects = new PhysicalContainer();
|
||||
createWorld(this.objects);
|
||||
this.objects.initialize();
|
||||
|
|
@ -80,30 +91,9 @@ export class GameServer extends CommandReceiver {
|
|||
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<Command> = 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 (e) {
|
||||
console.error('Failed to register joining player; disconnecting socket', e);
|
||||
socket.disconnect();
|
||||
}
|
||||
});
|
||||
socket.on(TransportEvents.PlayerJoining, (playerInfo: PlayerInformation) =>
|
||||
this.handleJoin(socket, playerInfo),
|
||||
);
|
||||
|
||||
socket.on(TransportEvents.SubscribeForServerInfoUpdates, () => {
|
||||
socket.join(gameStateSubscribedRoom);
|
||||
|
|
@ -111,6 +101,133 @@ export class GameServer extends CommandReceiver {
|
|||
});
|
||||
}
|
||||
|
||||
private readonly joinedSockets = new Map<Socket, JoinedSocket>();
|
||||
|
||||
private handleJoin(socket: Socket, playerInfo: PlayerInformation) {
|
||||
if (this.joinedSockets.has(socket)) {
|
||||
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.AlreadyJoined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isInEndGame) {
|
||||
// The round is already over and the world is about to be rebuilt; joining
|
||||
// now drops the player into an arena running in slow motion with no
|
||||
// end-of-round card.
|
||||
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.RoundEnding);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.players.isFull) {
|
||||
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.ServerFull);
|
||||
return;
|
||||
}
|
||||
|
||||
let player: Player;
|
||||
try {
|
||||
player = this.players.createPlayer(playerInfo, socket);
|
||||
} catch (e) {
|
||||
console.error('Failed to register joining player', e);
|
||||
socket.emit(
|
||||
TransportEvents.JoinRejected,
|
||||
e instanceof ServerFullError
|
||||
? JoinRejectionReason.ServerFull
|
||||
: JoinRejectionReason.InvalidRequest,
|
||||
);
|
||||
socket.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
const onPlayerToServer = (json: string) => {
|
||||
try {
|
||||
if (typeof json !== 'string' || json.length > settings.maxInboundMessageBytes) {
|
||||
return;
|
||||
}
|
||||
if (!this.allowInboundMessage(socket)) {
|
||||
return;
|
||||
}
|
||||
const commands: Array<Command> = deserialize(json);
|
||||
if (!Array.isArray(commands)) {
|
||||
return;
|
||||
}
|
||||
commands.forEach((c) => player.handleCommand(c));
|
||||
} catch (e) {
|
||||
console.error('Error while processing command', e);
|
||||
}
|
||||
};
|
||||
|
||||
const onDisconnect = () => {
|
||||
const record = this.joinedSockets.get(socket);
|
||||
this.joinedSockets.delete(socket);
|
||||
// The bucket is keyed by the Socket object, so leaving the entry behind
|
||||
// pins the socket for the rest of the round.
|
||||
this.inboundBudget.delete(socket);
|
||||
if (!record) {
|
||||
return;
|
||||
}
|
||||
record.player.detachFromSocket();
|
||||
const { kills, deaths } = player.scoreSnapshot;
|
||||
// Hold the score briefly so a client whose transport blipped can rejoin as
|
||||
// itself rather than as a blank slate.
|
||||
this.players.reserveScore(
|
||||
player.reconnectToken,
|
||||
player.team,
|
||||
kills,
|
||||
deaths,
|
||||
Date.now(),
|
||||
);
|
||||
player.destroy();
|
||||
this.players.deletePlayer(player);
|
||||
this.sendServerStateUpdate();
|
||||
};
|
||||
|
||||
socket.on(TransportEvents.PlayerToServer, onPlayerToServer);
|
||||
socket.on('disconnect', onDisconnect);
|
||||
this.joinedSockets.set(socket, { player, onPlayerToServer, onDisconnect });
|
||||
|
||||
player.reconnectToken = this.players.issueToken();
|
||||
socket.emit(TransportEvents.PlayerJoined, player.reconnectToken);
|
||||
|
||||
this.sendServerStateUpdate();
|
||||
}
|
||||
|
||||
// Cheap per-socket token bucket, sized well above what a legitimate client
|
||||
// produces so it only ever trips on a flood.
|
||||
private readonly inboundBudget = new Map<Socket, { tokens: number; lastMs: number }>();
|
||||
private droppedInboundMessages = 0;
|
||||
private allowInboundMessage(socket: Socket): boolean {
|
||||
const nowMs = Date.now();
|
||||
const burst = settings.maxInboundMessageBurst;
|
||||
const perSecond = settings.maxInboundMessagesPerSecond;
|
||||
|
||||
const budget = this.inboundBudget.get(socket) ?? { tokens: burst, lastMs: nowMs };
|
||||
budget.tokens = Math.min(
|
||||
burst,
|
||||
budget.tokens + ((nowMs - budget.lastMs) / 1000) * perSecond,
|
||||
);
|
||||
budget.lastMs = nowMs;
|
||||
this.inboundBudget.set(socket, budget);
|
||||
|
||||
if (budget.tokens < 1) {
|
||||
this.droppedInboundMessages++;
|
||||
return false;
|
||||
}
|
||||
|
||||
budget.tokens -= 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Detach the per-join listeners of every socket still attached to the round
|
||||
// that is ending, so a client that survives the restart can join the new one.
|
||||
private releaseJoinedSockets() {
|
||||
for (const [socket, record] of this.joinedSockets) {
|
||||
socket.off(TransportEvents.PlayerToServer, record.onPlayerToServer);
|
||||
socket.off('disconnect', record.onDisconnect);
|
||||
record.player.detachFromSocket();
|
||||
}
|
||||
this.joinedSockets.clear();
|
||||
this.inboundBudget.clear();
|
||||
}
|
||||
|
||||
private timeSinceLastServerStateUpdate = 0;
|
||||
public sendServerStateUpdate() {
|
||||
this.io
|
||||
|
|
@ -225,6 +342,7 @@ export class GameServer extends CommandReceiver {
|
|||
this.players.step(scaledDelta);
|
||||
}
|
||||
|
||||
beginPropertyUpdateGeneration();
|
||||
this.players.stepCommunication(delta);
|
||||
this.objects.resetRemoteCalls();
|
||||
|
||||
|
|
@ -259,10 +377,18 @@ export class GameServer extends CommandReceiver {
|
|||
rtts.sort((a, b) => a - b);
|
||||
console.info(
|
||||
`Player RTT median ${rtts[Math.floor(rtts.length / 2)].toFixed(0)} ms ` +
|
||||
`(min ${rtts[0].toFixed(0)}, max ${rtts[rtts.length - 1].toFixed(0)}, n=${rtts.length})`,
|
||||
`(min ${rtts[0].toFixed(0)}, max ${rtts[rtts.length - 1].toFixed(0)}, n=${rtts.length})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.droppedInboundMessages > 0) {
|
||||
console.warn(
|
||||
`Rate limited ${this.droppedInboundMessages} inbound message(s) since last report — ` +
|
||||
'client input was discarded',
|
||||
);
|
||||
this.droppedInboundMessages = 0;
|
||||
}
|
||||
|
||||
if (this.saturatedFrames > 0) {
|
||||
console.warn(
|
||||
`Physics saturated on ${this.saturatedFrames} frame(s) since last report — shedding backlog`,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Server as IoServer } from 'socket.io';
|
|||
import express from 'express';
|
||||
import { Server } from 'http';
|
||||
import cors from 'cors';
|
||||
import { applyArrayPlugins, Random, serverInformationEndpoint } from 'shared';
|
||||
import { applyArrayPlugins, Random, serverInformationEndpoint, settings } from 'shared';
|
||||
import minimist from 'minimist';
|
||||
import { glMatrix } from 'gl-matrix';
|
||||
import { GameServer } from './game-server';
|
||||
|
|
@ -24,6 +24,7 @@ const app = express();
|
|||
const server = new Server(app);
|
||||
const io = new IoServer(server, {
|
||||
parser,
|
||||
maxHttpBufferSize: settings.maxInboundMessageBytes,
|
||||
cors: {
|
||||
origin: true,
|
||||
credentials: true,
|
||||
|
|
|
|||
|
|
@ -171,17 +171,25 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
};
|
||||
}
|
||||
|
||||
private hasFiredSinceSpawn = false;
|
||||
private get isSpawnProtected(): boolean {
|
||||
return (
|
||||
!this.hasFiredSinceSpawn &&
|
||||
this.timeAlive <
|
||||
settings.spawnDespawnTime + settings.spawnInvulnerabilityExtraSeconds
|
||||
);
|
||||
}
|
||||
|
||||
private hasGeneratedPoints = false;
|
||||
private wasKilledInCombat = false;
|
||||
private getPoints(game: CommandReceiver) {
|
||||
if (!this.isAlive && !this.hasGeneratedPoints) {
|
||||
this.hasGeneratedPoints = true;
|
||||
|
||||
if (!this.wasKilledInCombat) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blue = this.team === CharacterTeam.blue ? 0 : settings.playerKillPoint;
|
||||
const red = this.team === CharacterTeam.red ? 0 : settings.playerKillPoint;
|
||||
|
||||
|
|
@ -260,7 +268,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
other.direction,
|
||||
mix(settings.deathImpulseMin, settings.deathImpulseMax, other.charge),
|
||||
);
|
||||
this.onDie();
|
||||
this.onDie(true);
|
||||
other.originator.addKill(this.name, other.charge);
|
||||
} else {
|
||||
other.originator.registerHit(other.charge);
|
||||
|
|
@ -268,7 +276,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
}
|
||||
}
|
||||
|
||||
public shootTowards(position: vec2, charge = 0) {
|
||||
public shootTowards(position: vec2, charge = 0, catchUpSeconds = 0) {
|
||||
if (
|
||||
!this.isAlive ||
|
||||
this.timeSinceLastShot < settings.projectileCreationInterval ||
|
||||
|
|
@ -277,21 +285,31 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
return;
|
||||
}
|
||||
|
||||
this.timeSinceLastShot = 0;
|
||||
const direction = vec2.subtract(vec2.create(), position, this.center);
|
||||
if (vec2.length(direction) === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const c = clamp01(charge);
|
||||
this.timeSinceLastShot = 0;
|
||||
this.hasFiredSinceSpawn = true;
|
||||
|
||||
const requestedCharge = clamp01(charge);
|
||||
const desiredStrength = mix(
|
||||
settings.chargeShotStrengthMin,
|
||||
settings.chargeShotStrengthMax,
|
||||
c,
|
||||
requestedCharge,
|
||||
);
|
||||
const strength = Math.min(desiredStrength, this.projectileStrength);
|
||||
this.projectileStrength -= strength;
|
||||
|
||||
const c = clamp01(
|
||||
(strength - settings.chargeShotStrengthMin) /
|
||||
(settings.chargeShotStrengthMax - settings.chargeShotStrengthMin),
|
||||
);
|
||||
|
||||
const radius = mix(settings.chargeShotRadiusMin, settings.chargeShotRadiusMax, c);
|
||||
const speed = mix(settings.chargeShotSpeedMin, settings.chargeShotSpeedMax, c);
|
||||
|
||||
const direction = vec2.subtract(vec2.create(), position, this.center);
|
||||
vec2.normalize(direction, direction);
|
||||
// Keep the unit direction before vec2.scale repurposes it as the velocity.
|
||||
const shotDirection = vec2.clone(direction);
|
||||
|
|
@ -307,6 +325,10 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
c,
|
||||
);
|
||||
this.container.addObject(projectile);
|
||||
// Lag compensation: advance the shot by the time the command spent reaching
|
||||
// us, so the shooter does not have to lead by their own latency as well as
|
||||
// by the projectile's travel time.
|
||||
projectile.fastForward(catchUpSeconds);
|
||||
|
||||
if (c > 0) {
|
||||
vec2.scaleAndAdd(
|
||||
|
|
@ -435,8 +457,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
),
|
||||
(this.rightFoot.radius - oldRightFoot.radius) / deltaTime,
|
||||
);
|
||||
|
||||
this.animateScaling(1);
|
||||
}
|
||||
|
||||
private step({ deltaTimeInSeconds, game }: StepCommand) {
|
||||
|
|
@ -467,6 +487,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
if (this.hasJustBorn) {
|
||||
if ((this.timeSinceBorn += deltaTimeInSeconds) > settings.spawnDespawnTime) {
|
||||
this.hasJustBorn = false;
|
||||
this.animateScaling(1);
|
||||
} else {
|
||||
this.animateScaling(this.timeSinceBorn / settings.spawnDespawnTime);
|
||||
}
|
||||
|
|
@ -486,7 +507,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
this.projectileStrength = Math.min(
|
||||
settings.playerMaxStrength,
|
||||
this.projectileStrength +
|
||||
settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds,
|
||||
settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds,
|
||||
);
|
||||
|
||||
this.regenerateHealth(deltaTimeInSeconds);
|
||||
|
|
@ -550,7 +571,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
}
|
||||
}
|
||||
|
||||
public onDie() {
|
||||
public onDie(killedInCombat = false) {
|
||||
this.wasKilledInCombat = killedInCombat;
|
||||
this.isDestroyed = true;
|
||||
this.remoteCall('onDie');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh
|
|||
) {
|
||||
super();
|
||||
this._boundingBox = new BoundingBox();
|
||||
this.recalculateBoundingBox();
|
||||
this.syncBoundingBox();
|
||||
}
|
||||
|
||||
public get boundingBox(): BoundingBoxBase {
|
||||
|
|
@ -52,7 +52,7 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh
|
|||
|
||||
public set center(value: vec2) {
|
||||
this._center = value;
|
||||
this.recalculateBoundingBox();
|
||||
this.syncBoundingBox();
|
||||
}
|
||||
|
||||
public onCollision(c: ReactToCollisionCommand) {
|
||||
|
|
@ -69,14 +69,14 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh
|
|||
|
||||
public set radius(value: number) {
|
||||
this._radius = value;
|
||||
this.recalculateBoundingBox();
|
||||
this.syncBoundingBox();
|
||||
}
|
||||
|
||||
public distance(target: vec2): number {
|
||||
return vec2.distance(target, this.center) - this.radius;
|
||||
}
|
||||
|
||||
private recalculateBoundingBox() {
|
||||
public syncBoundingBox() {
|
||||
this._boundingBox.xMin = this.center.x - this._radius;
|
||||
this._boundingBox.xMax = this.center.x + this._radius;
|
||||
this._boundingBox.yMin = this.center.y - this._radius;
|
||||
|
|
@ -121,6 +121,9 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh
|
|||
},
|
||||
);
|
||||
|
||||
// The body has moved; re-register it where it actually is.
|
||||
this.syncBoundingBox();
|
||||
|
||||
return { hitObject: (hitObject as Physical | undefined)?.gameObject, velocity };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ export class LampPhysical extends LampBase implements StaticPhysical {
|
|||
}
|
||||
|
||||
public queueSetLight(color: vec3, lightness: number) {
|
||||
this.color = vec3.clone(color);
|
||||
this.lightness = lightness;
|
||||
this.remoteCall('setLight', color, lightness);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
|||
|
||||
const sizeClass = clamp01(
|
||||
(this.radius - settings.planetMinReferenceRadius) /
|
||||
(settings.planetMaxReferenceRadius - settings.planetMinReferenceRadius),
|
||||
(settings.planetMaxReferenceRadius - settings.planetMinReferenceRadius),
|
||||
);
|
||||
|
||||
this.sizePointMultiplier = mix(1, settings.planetSizePointMultiplierMax, sizeClass);
|
||||
|
|
@ -222,8 +222,25 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
|||
}
|
||||
}
|
||||
|
||||
// hysteresis
|
||||
private get flipTeam(): CharacterTeam {
|
||||
const control = this.ownership - 0.5;
|
||||
const enter = settings.planetControlThreshold + settings.planetFlipHysteresis;
|
||||
|
||||
if (control > enter) {
|
||||
return CharacterTeam.red;
|
||||
}
|
||||
if (control < -enter) {
|
||||
return CharacterTeam.blue;
|
||||
}
|
||||
if (Math.abs(control) < settings.planetControlThreshold) {
|
||||
return CharacterTeam.neutral;
|
||||
}
|
||||
return this.lastTeam;
|
||||
}
|
||||
|
||||
private detectFlip(game: CommandReceiver) {
|
||||
const currentTeam = this.team;
|
||||
const currentTeam = this.flipTeam;
|
||||
if (currentTeam === this.lastTeam) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,15 +73,38 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
|
|||
vec2.normalize(vec2.create(), this.velocity),
|
||||
10,
|
||||
);
|
||||
while (wasCollision) {
|
||||
// Bounded: each pass advances by `delta`, but a degenerate direction would
|
||||
// otherwise spin here forever inside the physics tick.
|
||||
let passes = 0;
|
||||
while (wasCollision && passes++ < 32) {
|
||||
const intersecting = this.container
|
||||
.findIntersecting(this.boundingBox)
|
||||
.filter((g) => g instanceof CharacterPhysical && g.team === this.team);
|
||||
const { hitSurface } = marchCircle(this.object, delta, intersecting, true);
|
||||
wasCollision = hitSurface;
|
||||
this.object.syncBoundingBox();
|
||||
}
|
||||
vec2.add(this.center, this.center, delta);
|
||||
vec2.add(this.center, this.center, delta);
|
||||
this.object.syncBoundingBox();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lag compensation. Runs the projectile forward by the time its command spent
|
||||
* travelling to the server, so a shot lands where the shooter aimed rather
|
||||
* than forcing them to lead by their own latency on top of the projectile's
|
||||
* travel time. Capped by settings.maxProjectileCatchUpSeconds so a bad or
|
||||
* forged timestamp cannot spawn a shot arbitrarily far downrange.
|
||||
*/
|
||||
public fastForward(seconds: number) {
|
||||
const step = settings.targetPhysicsDeltaTimeInSeconds;
|
||||
let remaining = Math.min(Math.max(seconds, 0), settings.maxProjectileCatchUpSeconds);
|
||||
|
||||
while (remaining > 0 && this.isAlive) {
|
||||
const delta = Math.min(step, remaining);
|
||||
this.advance(delta);
|
||||
remaining -= delta;
|
||||
}
|
||||
}
|
||||
|
||||
public get boundingBox(): ImmutableBoundingBox {
|
||||
|
|
@ -123,6 +146,10 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
|
|||
}
|
||||
|
||||
private handleStep({ deltaTimeInSeconds }: StepCommand) {
|
||||
this.advance(deltaTimeInSeconds);
|
||||
}
|
||||
|
||||
private advance(deltaTimeInSeconds: number) {
|
||||
super.step(deltaTimeInSeconds);
|
||||
|
||||
if (this.strength <= 0) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
CharacterTeam,
|
||||
Random,
|
||||
settings,
|
||||
sanitizeName,
|
||||
} from 'shared';
|
||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
||||
|
|
@ -14,6 +15,8 @@ import { CharacterPhysical } from '../objects/character-physical';
|
|||
import { PlanetPhysical } from '../objects/planet-physical';
|
||||
import { PlayerContainer } from './player-container';
|
||||
|
||||
const maximumNameLength = 40;
|
||||
|
||||
export abstract class PlayerBase extends CommandReceiver {
|
||||
public character?: CharacterPhysical | null;
|
||||
public center: vec2 = vec2.create();
|
||||
|
|
@ -32,7 +35,12 @@ export abstract class PlayerBase extends CommandReceiver {
|
|||
|
||||
protected createCharacter() {
|
||||
this.character = new CharacterPhysical(
|
||||
this.playerInfo.name.slice(0, 20),
|
||||
// Coerced, not just truncated. `.slice()` also exists on arrays, so a
|
||||
// name that is an array passed straight through, was serialized into
|
||||
// CreateObjects, and was revived as a class on every peer that received
|
||||
// it — throwing inside the deserializer's reviver and killing that
|
||||
// client's whole message batch.
|
||||
sanitizeName(this.playerInfo.name, maximumNameLength),
|
||||
this.sumKills,
|
||||
this.sumDeaths,
|
||||
this.team,
|
||||
|
|
@ -72,10 +80,17 @@ export abstract class PlayerBase extends CommandReceiver {
|
|||
return vec2.clone(Random.choose(safe.length ? safe : candidates)!.center);
|
||||
}
|
||||
|
||||
// Seed a reconnecting player with the score it held before the drop, so a
|
||||
// network blip costs you your character but not your match.
|
||||
public restoreScore(kills: number, deaths: number) {
|
||||
this.sumKills = kills;
|
||||
this.sumDeaths = deaths;
|
||||
}
|
||||
|
||||
protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 {
|
||||
let rotation = 0;
|
||||
let radius = 0;
|
||||
for (;;) {
|
||||
for (let attempt = 0; attempt < 512; attempt++) {
|
||||
const playerPosition = vec2.fromValues(
|
||||
radius * Math.cos(rotation) + preferredCenter.x,
|
||||
radius * Math.sin(rotation) + preferredCenter.y,
|
||||
|
|
@ -96,6 +111,8 @@ export abstract class PlayerBase extends CommandReceiver {
|
|||
rotation += Math.PI / 8;
|
||||
radius += 30;
|
||||
}
|
||||
|
||||
return vec2.clone(preferredCenter);
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
|
|
|
|||
|
|
@ -4,10 +4,22 @@ import { PhysicalContainer } from '../physics/containers/physical-container';
|
|||
import { NPC } from './npc';
|
||||
import { Player } from './player';
|
||||
import { PlayerBase } from './player-base';
|
||||
import { ServerFullError } from './server-full-error';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
// Score held for a player whose socket dropped, so a reconnect inside the grace
|
||||
// window resumes the match instead of starting from zero.
|
||||
interface ReservedScore {
|
||||
team: CharacterTeam;
|
||||
kills: number;
|
||||
deaths: number;
|
||||
expiresAtMs: number;
|
||||
}
|
||||
|
||||
export class PlayerContainer {
|
||||
private _players: Array<Player> = [];
|
||||
private _npcs: Array<NPC> = [];
|
||||
private reservedScores = new Map<string, ReservedScore>();
|
||||
|
||||
constructor(
|
||||
private readonly objects: PhysicalContainer,
|
||||
|
|
@ -31,11 +43,15 @@ export class PlayerContainer {
|
|||
}
|
||||
|
||||
public createPlayer(playerInfo: PlayerInformation, socket: Socket): Player {
|
||||
if (this._players.length === this.playerMaxCount) {
|
||||
throw new Error('Too many players');
|
||||
if (this._players.length >= this.playerMaxCount) {
|
||||
throw new ServerFullError();
|
||||
}
|
||||
|
||||
const team = this.getTeamOfNextPlayer();
|
||||
const reserved = this.claimReservedScore(playerInfo.reconnectToken);
|
||||
const team = reserved ? reserved.team : this.getTeamOfNextPlayer();
|
||||
|
||||
const player = new Player(playerInfo, this, this.objects, team, socket);
|
||||
|
||||
let npcToReplace = this._npcs.find((n) => n.team === team);
|
||||
if (!npcToReplace) {
|
||||
npcToReplace = this._npcs.find((n) => n.team !== team);
|
||||
|
|
@ -43,12 +59,63 @@ export class PlayerContainer {
|
|||
npcToReplace?.destroy();
|
||||
this._npcs = this._npcs.filter((n) => n !== npcToReplace);
|
||||
|
||||
const player = new Player(playerInfo, this, this.objects, team, socket);
|
||||
this._players.push(player);
|
||||
|
||||
if (reserved) {
|
||||
player.restoreScore(reserved.kills, reserved.deaths);
|
||||
}
|
||||
|
||||
return player;
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh token for a joining player. Handed to the client immediately; it
|
||||
* only becomes redeemable once the player actually drops (see reserveScore).
|
||||
*/
|
||||
public issueToken(): string {
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold a dropped player's team and score against its token for the grace
|
||||
* window, so a client whose transport blipped rejoins as itself.
|
||||
*/
|
||||
public reserveScore(
|
||||
token: string,
|
||||
team: CharacterTeam,
|
||||
kills: number,
|
||||
deaths: number,
|
||||
nowMs: number,
|
||||
) {
|
||||
this.expireReservations(nowMs);
|
||||
this.reservedScores.set(token, {
|
||||
team,
|
||||
kills,
|
||||
deaths,
|
||||
expiresAtMs: nowMs + settings.reconnectGraceSeconds * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
private claimReservedScore(token?: string): ReservedScore | undefined {
|
||||
if (!token || typeof token !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
const reserved = this.reservedScores.get(token);
|
||||
if (!reserved) {
|
||||
return undefined;
|
||||
}
|
||||
this.reservedScores.delete(token);
|
||||
return reserved;
|
||||
}
|
||||
|
||||
private expireReservations(nowMs: number) {
|
||||
for (const [token, reserved] of this.reservedScores) {
|
||||
if (reserved.expiresAtMs <= nowMs) {
|
||||
this.reservedScores.delete(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public get players(): Array<PlayerBase> {
|
||||
return [...this._players, ...this._npcs];
|
||||
}
|
||||
|
|
@ -57,6 +124,10 @@ export class PlayerContainer {
|
|||
return this._players.length;
|
||||
}
|
||||
|
||||
public get isFull(): boolean {
|
||||
return this._players.length >= this.playerMaxCount;
|
||||
}
|
||||
|
||||
// Measured round-trip times (ms) of the real connected players, for
|
||||
// server-side latency stats. NPCs have no socket and are excluded.
|
||||
public get connectedPlayerRttsMs(): Array<number> {
|
||||
|
|
@ -96,7 +167,13 @@ export class PlayerContainer {
|
|||
}
|
||||
|
||||
public deletePlayer(player: Player) {
|
||||
const had = this._players.includes(player);
|
||||
this._players = this._players.filter((p) => p !== player);
|
||||
this.createNPCs();
|
||||
// Only refill bots if this player was actually ours. A socket left over from
|
||||
// a previous round reports its disconnect against the new container, and
|
||||
// topping up on that would over-fill the roster.
|
||||
if (had) {
|
||||
this.createNPCs();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,16 +25,30 @@ import {
|
|||
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';
|
||||
|
||||
// How often the server pings each client to measure round-trip time.
|
||||
const pingIntervalSeconds = 1;
|
||||
|
||||
// Aspect ratio is used to size the streamed view area; a zero, negative or
|
||||
// non-finite value collapses it and the player stops receiving the world.
|
||||
const minimumAspectRatio = 0.2;
|
||||
const maximumAspectRatio = 8;
|
||||
|
||||
// How much unflushed data on the socket counts as "this client is not keeping
|
||||
// up". See sendQueuedCommandsToClient.
|
||||
const maximumBufferedBytes = settings.maxBufferedBytesPerClient;
|
||||
|
||||
export class Player extends PlayerBase {
|
||||
// default, until the clients sends its real value
|
||||
private aspectRatio: number = 16 / 9;
|
||||
|
|
@ -42,34 +56,98 @@ export class Player extends PlayerBase {
|
|||
private timeSinceLastMessage = 0;
|
||||
private objectsPreviouslyInViewArea: Array<GameObject> = [];
|
||||
private lastInputClientTimeMs = 0;
|
||||
private lastInputReceiptMs = 0;
|
||||
private lastLeapClientTimeMs = 0;
|
||||
|
||||
// Measured round-trip time to this client (ms) — the latency primitive that
|
||||
// lag compensation, a latency HUD, and adaptive interpolation build on.
|
||||
// Token this session was issued at join. On a drop it is what the held score
|
||||
// is filed under, and what the returning client presents to reclaim it.
|
||||
public reconnectToken = '';
|
||||
|
||||
// Measured round-trip time to this client (ms) — 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 currently awaiting a reply, cleared once answered. Without
|
||||
// it a Pong was just a bare event: any client could send a stream of them, or
|
||||
// answer a ping it had sat on for a minute, and the RTT it produced feeds
|
||||
// projectile lag compensation.
|
||||
private pendingPingNonce = 0;
|
||||
private nextPingNonce = 1;
|
||||
|
||||
// Nothing below trusts a field it was handed. Every one of these arrives
|
||||
// straight from `deserialize`, which is a bare JSON.parse — and JSON carries
|
||||
// Infinity happily as `1e999`. A single non-finite number reaching the
|
||||
// simulation is unrecoverable: it spreads through the shared SDF and leaves a
|
||||
// character that never dies and never respawns.
|
||||
protected commandExecutors: CommandExecutors = {
|
||||
[SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) =>
|
||||
(this.aspectRatio = v.aspectRatio),
|
||||
[SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) => {
|
||||
this.aspectRatio = finiteInRange(
|
||||
v.aspectRatio,
|
||||
minimumAspectRatio,
|
||||
maximumAspectRatio,
|
||||
this.aspectRatio,
|
||||
);
|
||||
},
|
||||
[MoveActionCommand.type]: (c: MoveActionCommand) => {
|
||||
// Remember how far into this client's input timeline we've consumed, to
|
||||
// echo back for client-side prediction reconciliation.
|
||||
this.lastInputClientTimeMs = c.clientTimeMs;
|
||||
this.character?.handleMovementAction(c);
|
||||
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),
|
||||
this.catchUpSeconds,
|
||||
);
|
||||
},
|
||||
[PrimaryActionCommand.type]: (c: PrimaryActionCommand) =>
|
||||
this.character?.shootTowards(c.position, c.charge),
|
||||
[LeapActionCommand.type]: (c: LeapActionCommand) => {
|
||||
if (!isFiniteNumber(c.clientTimeMs)) {
|
||||
return;
|
||||
}
|
||||
// Record receipt (whether or not leap() accepts it): either way its effect
|
||||
// on bodyVelocity is now reflected in the streamed launch momentum, so the
|
||||
// predictor must stop replaying this leap.
|
||||
this.lastLeapClientTimeMs = c.clientTimeMs;
|
||||
this.observeClientTime(c.clientTimeMs);
|
||||
this.character?.leap();
|
||||
},
|
||||
// Closes every batch. Movement is only sent when it changes, so a held key
|
||||
// used to freeze the acknowledged input time — and with it the window the
|
||||
// client's predictor is allowed to replay. This advances the acknowledgement
|
||||
// at the client's send rate regardless of whether the input changed.
|
||||
[ClientHeartbeatCommand.type]: (c: ClientHeartbeatCommand) =>
|
||||
this.observeClientTime(c.clientTimeMs),
|
||||
};
|
||||
|
||||
// The newest client-clock instant whose commands have all been applied, and
|
||||
// the server-clock instant it arrived at. The client's predictor needs both:
|
||||
// the first tells it which of its inputs are already reflected in the
|
||||
// snapshot, the second how stale that answer was by the time the snapshot was
|
||||
// taken (see InputAcknowledgement.ackAgeMs).
|
||||
private observeClientTime(clientTimeMs: number) {
|
||||
if (isFiniteNumber(clientTimeMs) && clientTimeMs > this.lastInputClientTimeMs) {
|
||||
this.lastInputClientTimeMs = clientTimeMs;
|
||||
this.lastInputReceiptMs = performance.now();
|
||||
}
|
||||
}
|
||||
|
||||
// How long a command spent reaching us, for projectile lag compensation.
|
||||
// Deliberately derived from the measured RTT and NOT from the client's own
|
||||
// timestamp, so a forged stamp cannot buy extra compensation — which is why it
|
||||
// takes no argument. ProjectilePhysical.fastForward caps the result.
|
||||
private get catchUpSeconds(): number {
|
||||
return Math.max(0, this.rttMs / 2 / 1000);
|
||||
}
|
||||
|
||||
constructor(
|
||||
playerInfo: PlayerInformation,
|
||||
playerContainer: PlayerContainer,
|
||||
|
|
@ -84,11 +162,29 @@ export class Player extends PlayerBase {
|
|||
// The client already echoes a Pong for every Ping (see game.ts). Only one
|
||||
// ping is ever in flight, so RTT is simply now − send-time; no payload
|
||||
// needed and no client change required.
|
||||
this.socket.on(TransportEvents.Pong, () => {
|
||||
if (this.lastPingSentMs > 0) {
|
||||
this.rttMs = performance.now() - this.lastPingSentMs;
|
||||
}
|
||||
});
|
||||
this.socket.on(TransportEvents.Pong, this.onPong);
|
||||
}
|
||||
|
||||
// Only the outstanding ping counts, and only once: a stale, duplicated or
|
||||
// invented reply is ignored. A client can still sit on the live ping to
|
||||
// inflate its measured RTT, so the result is clamped — and the next ping
|
||||
// retires the nonce, bounding the 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,
|
||||
);
|
||||
};
|
||||
|
||||
// Fix 7a: the Pong listener outlives the round it belongs to unless it is
|
||||
// taken off explicitly — the socket survives a restart, so a retired Player
|
||||
// would stay reachable (and keep updating) through it.
|
||||
public detachFromSocket() {
|
||||
this.socket.off(TransportEvents.Pong, this.onPong);
|
||||
}
|
||||
|
||||
protected createCharacter() {
|
||||
|
|
@ -103,6 +199,11 @@ export class Player extends PlayerBase {
|
|||
this.winnerTeam = winnerTeam;
|
||||
}
|
||||
|
||||
// The corpse a projectile may still credit a kill to. Kept so a shot that was
|
||||
// already in flight when we died is not thrown away: killCount is re-read at
|
||||
// respawn instead of being snapshotted the instant the body died.
|
||||
private dyingCharacter?: CharacterPhysical | null;
|
||||
|
||||
public step(deltaTimeInSeconds: number) {
|
||||
if (this.character) {
|
||||
this.center = this.character?.center;
|
||||
|
|
@ -111,11 +212,16 @@ export class Player extends PlayerBase {
|
|||
this.sumDeaths++;
|
||||
this.sumKills = this.character.killCount;
|
||||
|
||||
this.dyingCharacter = this.character;
|
||||
this.character = null;
|
||||
this.timeUntilRespawn = settings.playerDiedTimeout;
|
||||
}
|
||||
} else {
|
||||
if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) {
|
||||
if (this.dyingCharacter) {
|
||||
this.sumKills = Math.max(this.sumKills, this.dyingCharacter.killCount);
|
||||
this.dyingCharacter = null;
|
||||
}
|
||||
this.createCharacter();
|
||||
this.center = this.character!.center;
|
||||
}
|
||||
|
|
@ -164,7 +270,7 @@ export class Player extends PlayerBase {
|
|||
this.queueCommandSend(
|
||||
new PropertyUpdatesForObjects(
|
||||
this.objectsPreviouslyInViewArea
|
||||
.map((o) => o.getPropertyUpdates())
|
||||
.map((o) => o.getPropertyUpdatesForFrame())
|
||||
.filter((u) => u) as Array<PropertyUpdatesForObject>,
|
||||
performance.now() / 1000,
|
||||
),
|
||||
|
|
@ -179,6 +285,9 @@ export class Player extends PlayerBase {
|
|||
this.lastInputClientTimeMs,
|
||||
this.character.launchMomentum,
|
||||
this.lastLeapClientTimeMs,
|
||||
this.lastInputReceiptMs > 0
|
||||
? Math.max(0, performance.now() - this.lastInputReceiptMs)
|
||||
: 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -212,7 +321,8 @@ export class Player extends PlayerBase {
|
|||
if ((this.timeSinceLastPing += deltaTime) > pingIntervalSeconds) {
|
||||
this.timeSinceLastPing = 0;
|
||||
this.lastPingSentMs = performance.now();
|
||||
this.socket.emit(TransportEvents.Ping);
|
||||
this.pendingPingNonce = this.nextPingNonce++;
|
||||
this.socket.emit(TransportEvents.Ping, this.pendingPingNonce);
|
||||
}
|
||||
|
||||
if ((this.timeSinceLastMessage += deltaTime) > settings.updateMessageInterval) {
|
||||
|
|
@ -223,11 +333,63 @@ export class Player extends PlayerBase {
|
|||
}
|
||||
}
|
||||
|
||||
// Bytes written to this client's socket but not yet flushed to the network.
|
||||
// engine.io's own Socket exposes no such number — it lives on the underlying
|
||||
// ws socket, and only the websocket transport has one — so reading
|
||||
// `socket.conn.bufferedAmount` (as this did) always produced undefined and the
|
||||
// backpressure check below never once ran.
|
||||
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;
|
||||
}
|
||||
|
||||
// A client that is not draining gets the cheap half of the snapshot shed:
|
||||
// piling more state behind it only grows a backlog it will never catch up
|
||||
// on, and the freshest property update is the only one that matters.
|
||||
//
|
||||
// Only the state that next tick regenerates in full may be dropped. Create
|
||||
// and delete are one-shot: handleViewAreaUpdate has ALREADY advanced
|
||||
// objectsPreviouslyInViewArea by the time this runs, so dropping them leaves
|
||||
// this client permanently missing objects it was never told about and
|
||||
// holding ghosts it was never told to remove. Announcements and the end-game
|
||||
// card are one-shot for the same reason. They are small; they go out even
|
||||
// while backed up.
|
||||
if (this.bufferedBytes > maximumBufferedBytes) {
|
||||
// Matched by command type rather than `instanceof`, the same way every
|
||||
// other dispatch in the codebase does it — the identity of a class from a
|
||||
// bundled shared module is not something to depend on.
|
||||
const sheddable: ReadonlyArray<string> = [
|
||||
PropertyUpdatesForObjects.type,
|
||||
UpdateMinimap.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 = [];
|
||||
}
|
||||
|
||||
/** Score to hold for this player if it reconnects inside the grace window. */
|
||||
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) {
|
||||
|
|
|
|||
5
backend/src/players/server-full-error.ts
Normal file
5
backend/src/players/server-full-error.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export class ServerFullError extends Error {
|
||||
constructor() {
|
||||
super('Too many players');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { glMatrix } from 'gl-matrix';
|
||||
import {
|
||||
applyArrayPlugins,
|
||||
LampBase,
|
||||
overrideDeserialization,
|
||||
PlanetBase,
|
||||
|
|
@ -29,6 +30,7 @@ import { PlanetView } from './scripts/objects/types/planet-view';
|
|||
import { ProjectileView } from './scripts/objects/types/projectile-view';
|
||||
|
||||
glMatrix.setMatrixArrayType(Array);
|
||||
applyArrayPlugins();
|
||||
|
||||
overrideDeserialization(CharacterBase, CharacterView);
|
||||
overrideDeserialization(PlanetBase, PlanetView);
|
||||
|
|
@ -53,6 +55,11 @@ const enableMusic = document.querySelector('#enable-music') as HTMLInputElement;
|
|||
const enableVibration = document.querySelector('#enable-vibration') as HTMLInputElement;
|
||||
const spinner = document.querySelector('#spinner-container') as HTMLElement;
|
||||
|
||||
const joinNotice = document.createElement('p');
|
||||
joinNotice.className = 'join-notice';
|
||||
joinNotice.style.display = 'none';
|
||||
joinGameForm.prepend(joinNotice);
|
||||
|
||||
const toggleSettings = () => {
|
||||
settings.className = settings.className === 'open' ? '' : 'open';
|
||||
SoundHandler.play(Sounds.click);
|
||||
|
|
@ -122,7 +129,7 @@ const main = async () => {
|
|||
});
|
||||
window.onpopstate = () => game.destroy();
|
||||
|
||||
for (;;) {
|
||||
for (; ;) {
|
||||
show(spinner);
|
||||
hide(logoutButton, true);
|
||||
show(landingUI, true, 'flex');
|
||||
|
|
@ -150,6 +157,14 @@ const main = async () => {
|
|||
hide(spinner);
|
||||
show(logoutButton, true, 'block');
|
||||
await gameOver;
|
||||
|
||||
const reason = game.lastRejectionReason;
|
||||
if (reason) {
|
||||
joinNotice.innerText = Game.rejectionText(reason);
|
||||
joinNotice.style.display = 'block';
|
||||
} else {
|
||||
joinNotice.style.display = 'none';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
|
|
|||
|
|
@ -247,9 +247,8 @@ body {
|
|||
|
||||
border-radius: 1000px;
|
||||
background-color: rgba(0, 0, 0, 0.35);
|
||||
box-shadow:
|
||||
inset 0 0 3px 0 rgba(0, 0, 0, 0.4),
|
||||
0 0 10px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: inset 0 0 3px 0 rgba(0, 0, 0, 0.4),
|
||||
0 0 10px rgba(0, 0, 0, 0.3);
|
||||
border: $border-width solid rgba(255, 255, 255, 0.25);
|
||||
z-index: 100;
|
||||
}
|
||||
|
|
@ -938,3 +937,28 @@ body {
|
|||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.connection-banner {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 6px 14px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
color: #fff;
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.02em;
|
||||
pointer-events: none;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.join-notice {
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
background: rgba(209, 86, 82, 0.18);
|
||||
color: #f2c9c7;
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
|
@ -1,5 +1,21 @@
|
|||
import { Command, CommandReceiver, serialize, TransportEvents } from 'shared';
|
||||
import {
|
||||
ClientHeartbeatCommand,
|
||||
Command,
|
||||
CommandReceiver,
|
||||
serialize,
|
||||
settings,
|
||||
TransportEvents,
|
||||
} from 'shared';
|
||||
import { Socket } from 'socket.io-client';
|
||||
import { predictorNowMs } from '../helper/prediction/local-character-predictor';
|
||||
|
||||
const heartbeatIntervalMs = settings.clientSendInterval * 1000;
|
||||
|
||||
// A frame that lands a hair short of the deadline still counts as due. At an
|
||||
// exact ratio of frame rate to interval — 60 Hz against 30 Hz — whether the gap
|
||||
// compares as reached is decided by floating-point noise, and the heartbeat
|
||||
// slips a whole frame every third beat, drifting to ~21 Hz instead of 30.
|
||||
const dueToleranceMs = 1;
|
||||
|
||||
export class CommandSocket extends CommandReceiver {
|
||||
constructor(private readonly socket: Socket) {
|
||||
|
|
@ -7,14 +23,35 @@ export class CommandSocket extends CommandReceiver {
|
|||
}
|
||||
|
||||
private commandQueue: Array<Command> = [];
|
||||
private lastSendMs = -Infinity;
|
||||
|
||||
protected defaultCommandExecutor(command: Command) {
|
||||
this.commandQueue.push(command);
|
||||
}
|
||||
|
||||
public sendQueuedCommands() {
|
||||
if (this.commandQueue.length > 0) {
|
||||
this.socket.emit(TransportEvents.PlayerToServer, serialize(this.commandQueue));
|
||||
this.commandQueue = [];
|
||||
const nowMs = predictorNowMs();
|
||||
|
||||
// Real input goes out on the frame it happens — that is the whole latency
|
||||
// budget the player feels. An idle client still has to keep the server's
|
||||
// input acknowledgement moving (a held key generates no fresh command), but
|
||||
// that heartbeat is paced by the clock rather than by the frame rate.
|
||||
//
|
||||
// Sending once per rendered frame instead put every display above
|
||||
// settings.maxInboundMessagesPerSecond permanently over the server's inbound
|
||||
// allowance; once the burst was spent it silently discarded whole batches,
|
||||
// and since movement commands are edge-triggered and never re-sent, a lost
|
||||
// batch meant the direction change simply never happened.
|
||||
if (
|
||||
this.commandQueue.length === 0 &&
|
||||
nowMs - this.lastSendMs < heartbeatIntervalMs - dueToleranceMs
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.lastSendMs = nowMs;
|
||||
|
||||
this.commandQueue.push(new ClientHeartbeatCommand(Math.round(nowMs)));
|
||||
this.socket.emit(TransportEvents.PlayerToServer, serialize(this.commandQueue));
|
||||
this.commandQueue = [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,20 +14,26 @@ export class KeyboardListener extends CommandGenerator {
|
|||
}
|
||||
|
||||
private keyDownListener = (event: KeyboardEvent) => {
|
||||
const key = event.key.toLowerCase();
|
||||
// Space leaps (W / ArrowUp already cover walking up). Edge-triggered so a
|
||||
// held key's auto-repeat doesn't spam leaps.
|
||||
if ((key === ' ' || key === 'shift') && !this.keysDown.has(key)) {
|
||||
const key = event.code;
|
||||
if (
|
||||
(key === 'Space' || key === 'ShiftLeft' || key === 'ShiftRight') &&
|
||||
!this.keysDown.has(key)
|
||||
) {
|
||||
const clientTimeMs = localCharacterPredictor.recordLeap();
|
||||
this.sendCommandToSubscribers(new LeapActionCommand(clientTimeMs));
|
||||
}
|
||||
|
||||
if (this.keysDown.has(key)) {
|
||||
return;
|
||||
}
|
||||
this.keysDown.add(key);
|
||||
this.generateCommands();
|
||||
};
|
||||
|
||||
private keyUpListener = (event: KeyboardEvent) => {
|
||||
this.keysDown.delete(event.key.toLowerCase());
|
||||
this.generateCommands();
|
||||
if (this.keysDown.delete(event.code)) {
|
||||
this.generateCommands();
|
||||
}
|
||||
};
|
||||
|
||||
private blurListener = () => {
|
||||
|
|
@ -36,10 +42,10 @@ export class KeyboardListener extends CommandGenerator {
|
|||
};
|
||||
|
||||
private generateCommands() {
|
||||
const up = ~~(this.keysDown.has('w') || this.keysDown.has('arrowup'));
|
||||
const down = ~~(this.keysDown.has('s') || this.keysDown.has('arrowdown'));
|
||||
const left = ~~(this.keysDown.has('a') || this.keysDown.has('arrowleft'));
|
||||
const right = ~~(this.keysDown.has('d') || this.keysDown.has('arrowright'));
|
||||
const up = ~~(this.keysDown.has('KeyW') || this.keysDown.has('ArrowUp'));
|
||||
const down = ~~(this.keysDown.has('KeyS') || this.keysDown.has('ArrowDown'));
|
||||
const left = ~~(this.keysDown.has('KeyA') || this.keysDown.has('ArrowLeft'));
|
||||
const right = ~~(this.keysDown.has('KeyD') || this.keysDown.has('ArrowRight'));
|
||||
|
||||
const movement = vec2.fromValues(right - left, up - down);
|
||||
if (vec2.squaredLength(movement) > 0) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { vec2 } from 'gl-matrix';
|
|||
import { CommandGenerator, PrimaryActionCommand, holdDurationToCharge } from 'shared';
|
||||
import { Game } from '../game';
|
||||
import { ChargeIndicator } from '../charge-indicator';
|
||||
import { predictorNowMs } from '../helper/prediction/local-character-predictor';
|
||||
import { Pointer } from '../helper/pointer';
|
||||
|
||||
export class MouseListener extends CommandGenerator {
|
||||
|
|
@ -16,9 +17,13 @@ export class MouseListener extends CommandGenerator {
|
|||
super();
|
||||
|
||||
target.addEventListener('mousedown', this.mouseDownListener);
|
||||
target.addEventListener('mouseup', this.mouseUpListener);
|
||||
target.addEventListener('mousemove', this.mouseMoveListener);
|
||||
target.addEventListener('contextmenu', this.contextMenuListener);
|
||||
// Release is watched on the window, not the canvas: a charge released over
|
||||
// the settings gear, the fullscreen icon, or outside the window otherwise
|
||||
// never fired the shot and left the charge ring up until the next press.
|
||||
window.addEventListener('mouseup', this.mouseUpListener);
|
||||
window.addEventListener('blur', this.cancelPrimary);
|
||||
}
|
||||
|
||||
// Only the screen position is stored; it is reprojected to world space each
|
||||
|
|
@ -46,10 +51,23 @@ export class MouseListener extends CommandGenerator {
|
|||
const charge = holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000);
|
||||
this.primaryDownAt = null;
|
||||
this.sendCommandToSubscribers(
|
||||
new PrimaryActionCommand(this.positionFromEvent(event), charge),
|
||||
new PrimaryActionCommand(
|
||||
this.positionFromEvent(event),
|
||||
charge,
|
||||
Math.round(predictorNowMs()),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
// Losing the window mid-charge cannot produce a meaningful aim point, so drop
|
||||
// the charge rather than leaving the indicator running.
|
||||
private cancelPrimary = () => {
|
||||
if (this.primaryDownAt !== null) {
|
||||
this.primaryDownAt = null;
|
||||
ChargeIndicator.end();
|
||||
}
|
||||
};
|
||||
|
||||
// Suppress the browser context menu on the canvas; right-click has no action.
|
||||
private contextMenuListener = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
|
|
@ -64,8 +82,9 @@ export class MouseListener extends CommandGenerator {
|
|||
public destroy() {
|
||||
ChargeIndicator.end();
|
||||
this.target.removeEventListener('mousedown', this.mouseDownListener);
|
||||
this.target.removeEventListener('mouseup', this.mouseUpListener);
|
||||
this.target.removeEventListener('mousemove', this.mouseMoveListener);
|
||||
this.target.removeEventListener('contextmenu', this.contextMenuListener);
|
||||
window.removeEventListener('mouseup', this.mouseUpListener);
|
||||
window.removeEventListener('blur', this.cancelPrimary);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ import {
|
|||
} from 'shared';
|
||||
import { Game } from '../game';
|
||||
import { ChargeIndicator } from '../charge-indicator';
|
||||
import { localCharacterPredictor } from '../helper/prediction/local-character-predictor';
|
||||
import {
|
||||
localCharacterPredictor,
|
||||
predictorNowMs,
|
||||
} from '../helper/prediction/local-character-predictor';
|
||||
|
||||
export class TouchListener extends CommandGenerator {
|
||||
private static readonly deadZone = 8;
|
||||
|
|
@ -24,6 +27,7 @@ export class TouchListener extends CommandGenerator {
|
|||
private isJoystickActive = false;
|
||||
private touchStartPosition!: vec2;
|
||||
private primaryDownAt: number | null = null;
|
||||
private gestureTouchId: number | null = null;
|
||||
|
||||
private fireButton: HTMLElement;
|
||||
private fireStrengthRing: HTMLElement;
|
||||
|
|
@ -69,23 +73,43 @@ export class TouchListener extends CommandGenerator {
|
|||
target.addEventListener('touchstart', this.touchStartListener);
|
||||
target.addEventListener('touchmove', this.touchMoveListener);
|
||||
target.addEventListener('touchend', this.touchEndListener);
|
||||
target.addEventListener('touchcancel', this.touchCancelListener);
|
||||
}
|
||||
|
||||
// The contact that started this gesture, or undefined if it is not in `list`.
|
||||
private findGestureTouch(list: TouchList): Touch | undefined {
|
||||
if (this.gestureTouchId === null) {
|
||||
return undefined;
|
||||
}
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (list[i].identifier === this.gestureTouchId) {
|
||||
return list[i];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private touchStartListener = (event: TouchEvent) => {
|
||||
event.preventDefault();
|
||||
if (this.isJoystickActive) {
|
||||
const center = vec2.fromValues(
|
||||
last(event.touches)!.clientX,
|
||||
last(event.touches)!.clientY,
|
||||
last(event.changedTouches)!.clientX,
|
||||
last(event.changedTouches)!.clientY,
|
||||
);
|
||||
this.sendCommandToSubscribers(
|
||||
new PrimaryActionCommand(this.game.displayToWorldCoordinates(center)),
|
||||
new PrimaryActionCommand(
|
||||
this.game.displayToWorldCoordinates(center),
|
||||
0,
|
||||
Math.round(predictorNowMs()),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
this.touchStartPosition = vec2.fromValues(
|
||||
event.touches[0].clientX,
|
||||
event.touches[0].clientY,
|
||||
);
|
||||
const touch = event.changedTouches[0];
|
||||
if (!touch) {
|
||||
return;
|
||||
}
|
||||
this.gestureTouchId = touch.identifier;
|
||||
this.touchStartPosition = vec2.fromValues(touch.clientX, touch.clientY);
|
||||
this.primaryDownAt = performance.now();
|
||||
ChargeIndicator.begin(this.touchStartPosition.x, this.touchStartPosition.y);
|
||||
}
|
||||
|
|
@ -94,10 +118,12 @@ export class TouchListener extends CommandGenerator {
|
|||
private touchMoveListener = (event: TouchEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const touchPosition = vec2.fromValues(
|
||||
event.touches[0].clientX,
|
||||
event.touches[0].clientY,
|
||||
);
|
||||
const touch = this.findGestureTouch(event.touches);
|
||||
if (!touch || !this.touchStartPosition) {
|
||||
return;
|
||||
}
|
||||
|
||||
const touchPosition = vec2.fromValues(touch.clientX, touch.clientY);
|
||||
|
||||
const delta = vec2.subtract(vec2.create(), touchPosition, this.touchStartPosition);
|
||||
vec2.scale(delta, delta, TouchListener.deltaScaling);
|
||||
|
|
@ -133,6 +159,14 @@ export class TouchListener extends CommandGenerator {
|
|||
private touchEndListener = (event: TouchEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
// Only the contact that owns the gesture ends it — not whichever finger
|
||||
// happens to lift, and not "the screen is now empty".
|
||||
const touch = this.findGestureTouch(event.changedTouches);
|
||||
if (!touch) {
|
||||
return;
|
||||
}
|
||||
this.gestureTouchId = null;
|
||||
|
||||
if (!this.isJoystickActive) {
|
||||
ChargeIndicator.end();
|
||||
const charge =
|
||||
|
|
@ -140,20 +174,39 @@ export class TouchListener extends CommandGenerator {
|
|||
? 0
|
||||
: holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000);
|
||||
this.primaryDownAt = null;
|
||||
const center = vec2.fromValues(
|
||||
event.changedTouches[0].clientX,
|
||||
event.changedTouches[0].clientY,
|
||||
);
|
||||
const center = vec2.fromValues(touch.clientX, touch.clientY);
|
||||
this.sendCommandToSubscribers(
|
||||
new PrimaryActionCommand(this.game.displayToWorldCoordinates(center), charge),
|
||||
new PrimaryActionCommand(
|
||||
this.game.displayToWorldCoordinates(center),
|
||||
charge,
|
||||
Math.round(predictorNowMs()),
|
||||
),
|
||||
);
|
||||
} else if (event.touches.length === 0) {
|
||||
this.isJoystickActive = false;
|
||||
this.joystick.parentElement?.removeChild(this.joystick);
|
||||
this.sendMove(vec2.create());
|
||||
} else {
|
||||
this.releaseJoystick();
|
||||
}
|
||||
};
|
||||
|
||||
// Also reached from touchcancel: a system gesture, an incoming call or a
|
||||
// palm rejection would otherwise leave the stick latched at full tilt.
|
||||
private touchCancelListener = (event: TouchEvent) => {
|
||||
if (!this.findGestureTouch(event.changedTouches)) {
|
||||
return;
|
||||
}
|
||||
this.gestureTouchId = null;
|
||||
this.primaryDownAt = null;
|
||||
ChargeIndicator.end();
|
||||
if (this.isJoystickActive) {
|
||||
this.releaseJoystick();
|
||||
}
|
||||
};
|
||||
|
||||
private releaseJoystick() {
|
||||
this.isJoystickActive = false;
|
||||
this.joystick.parentElement?.removeChild(this.joystick);
|
||||
this.sendMove(vec2.create());
|
||||
}
|
||||
|
||||
private swallowTouch = (event: TouchEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
|
@ -237,7 +290,9 @@ export class TouchListener extends CommandGenerator {
|
|||
direction,
|
||||
settings.touchAimRange,
|
||||
);
|
||||
this.sendCommandToSubscribers(new PrimaryActionCommand(aim, charge));
|
||||
this.sendCommandToSubscribers(
|
||||
new PrimaryActionCommand(aim, charge, Math.round(predictorNowMs())),
|
||||
);
|
||||
};
|
||||
|
||||
public update(_deltaTimeInSeconds: number) {
|
||||
|
|
@ -250,9 +305,8 @@ export class TouchListener extends CommandGenerator {
|
|||
|
||||
const character = this.game.gameObjects.player;
|
||||
if (character) {
|
||||
this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${
|
||||
character.strengthFraction * 360
|
||||
}deg, transparent 0deg)`;
|
||||
this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${character.strengthFraction * 360
|
||||
}deg, transparent 0deg)`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -261,6 +315,7 @@ export class TouchListener extends CommandGenerator {
|
|||
this.target.removeEventListener('touchstart', this.touchStartListener);
|
||||
this.target.removeEventListener('touchmove', this.touchMoveListener);
|
||||
this.target.removeEventListener('touchend', this.touchEndListener);
|
||||
this.target.removeEventListener('touchcancel', this.touchCancelListener);
|
||||
|
||||
this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener);
|
||||
this.fireButton.removeEventListener('touchmove', this.fireButtonMoveListener);
|
||||
|
|
|
|||
|
|
@ -99,6 +99,18 @@ export abstract class FeedbackHud {
|
|||
this.elimination = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear the HUD down entirely. Its root lives on document.body rather than the
|
||||
* game overlay, so leaving a match while dead used to leave "Eliminated /
|
||||
* Respawning…" painted over the landing page.
|
||||
*/
|
||||
public static reset(): void {
|
||||
this.hideElimination();
|
||||
this.root?.parentElement?.removeChild(this.root);
|
||||
this.root = undefined;
|
||||
this.killfeed = undefined;
|
||||
}
|
||||
|
||||
private static streakName(streak: number): string | undefined {
|
||||
switch (streak) {
|
||||
case 2:
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
Command,
|
||||
settings,
|
||||
InputAcknowledgement,
|
||||
JoinRejectionReason,
|
||||
} from 'shared';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { KeyboardListener } from './commands/keyboard-listener';
|
||||
|
|
@ -40,6 +41,7 @@ import { Tutorial } from './tutorial';
|
|||
import { Scoreboard } from './scoreboard';
|
||||
import { Minimap } from './minimap';
|
||||
import { ScreenShake } from './screen-shake';
|
||||
import { FeedbackHud } from './feedback-hud';
|
||||
|
||||
export class Game extends CommandReceiver {
|
||||
public gameObjects = new GameObjectContainer(this);
|
||||
|
|
@ -60,6 +62,11 @@ export class Game extends CommandReceiver {
|
|||
private keystoneArrow?: HTMLElement;
|
||||
private socketReceiver!: CommandSocket;
|
||||
private tutorial!: Tutorial;
|
||||
// Issued by the server on join; presented on reconnect to reclaim this
|
||||
// player's team and score instead of coming back as a blank slate.
|
||||
private reconnectToken?: string;
|
||||
private connectionBanner?: HTMLElement;
|
||||
private rejectionReason?: JoinRejectionReason;
|
||||
|
||||
constructor(
|
||||
private readonly playerDecision: PlayerDecision,
|
||||
|
|
@ -110,19 +117,76 @@ export class Game extends CommandReceiver {
|
|||
this.socket.io.opts.transports = ['polling', 'websocket'];
|
||||
});
|
||||
|
||||
// A transport drop is not the end of the match. Previously this tore the
|
||||
// game down on the first `disconnect`, which stopped the render loop, which
|
||||
// closed the socket — cancelling the reconnection the client is configured
|
||||
// for before it could ever run, and dumping the player on the server list.
|
||||
this.socket.on('disconnect', () => {
|
||||
if (!this.isBetweenGames) {
|
||||
this.destroy();
|
||||
if (this.isBetweenGames) {
|
||||
return;
|
||||
}
|
||||
this.showConnectionBanner('Reconnecting…');
|
||||
});
|
||||
|
||||
this.socket.on(TransportEvents.Ping, () => {
|
||||
this.socket.emit(TransportEvents.Pong);
|
||||
this.socket.on('connect', () => {
|
||||
if (this.isBetweenGames) {
|
||||
return;
|
||||
}
|
||||
// A reconnect is a brand-new server-side connection that has never seen a
|
||||
// join, so the join has to be re-sent or the client sits connected and
|
||||
// invisible forever.
|
||||
this.hideConnectionBanner();
|
||||
serverTimeline.reset();
|
||||
localCharacterPredictor.reset();
|
||||
// ...and it is a brand-new server-side *player*, whose view-area
|
||||
// bookkeeping starts empty. Anything the dropped session was told about
|
||||
// will never be retracted, so the old world has to go here.
|
||||
this.gameObjects.reset();
|
||||
this.socket.emit(TransportEvents.PlayerJoining, {
|
||||
...this.playerDecision,
|
||||
reconnectToken: this.reconnectToken,
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
// Echo the nonce: the server only accepts a reply that matches the ping
|
||||
// still outstanding, so a stale or duplicated Pong cannot move its RTT.
|
||||
this.socket.on(TransportEvents.Ping, (nonce: unknown) => {
|
||||
this.socket.emit(TransportEvents.Pong, nonce);
|
||||
});
|
||||
|
||||
this.socket.on(TransportEvents.ServerToPlayer, (serializedCommands: string) => {
|
||||
const commands: Array<Command> = deserialize(serializedCommands);
|
||||
commands.forEach((c) => this.handleCommand(c));
|
||||
// One malformed object must not take down the message pump. deserialize
|
||||
// revives classes by name from the payload, so a hostile or corrupt field
|
||||
// can throw inside JSON.parse's reviver — and every later batch would be
|
||||
// lost with it.
|
||||
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.socketReceiver = new CommandSocket(this.socket);
|
||||
|
|
@ -138,9 +202,10 @@ export class Game extends CommandReceiver {
|
|||
this.touchListener.subscribe(this.socketReceiver);
|
||||
this.touchListener.subscribe(this.tutorial);
|
||||
|
||||
// The join is emitted from the socket's `connect` handler above, which fires
|
||||
// for the first connection and for every reconnection alike — so one code
|
||||
// path covers both, and a reconnect can never be left unjoined.
|
||||
this.isBetweenGames = false;
|
||||
|
||||
this.socket.emit(TransportEvents.PlayerJoining, this.playerDecision);
|
||||
}
|
||||
|
||||
protected defaultCommandExecutor(c: Command) {
|
||||
|
|
@ -163,6 +228,7 @@ export class Game extends CommandReceiver {
|
|||
c.clientTimeMs,
|
||||
c.bodyVelocity,
|
||||
c.lastLeapClientTimeMs,
|
||||
c.ackAgeMs,
|
||||
),
|
||||
[GameEndCommand.type]: () => (this.isEnding = true),
|
||||
[UpdateMinimap.type]: (c: UpdateMinimap) => (this.lastMinimap = c),
|
||||
|
|
@ -208,11 +274,49 @@ export class Game extends CommandReceiver {
|
|||
);
|
||||
this.socket.close();
|
||||
this.overlay.innerHTML = '';
|
||||
this.hideConnectionBanner();
|
||||
// The HUD root lives on document.body, not the overlay, so it has to be torn
|
||||
// down explicitly or it stays painted over the landing page.
|
||||
FeedbackHud.reset();
|
||||
this.keyboardListener.destroy();
|
||||
this.mouseListener.destroy();
|
||||
this.touchListener.destroy();
|
||||
}
|
||||
|
||||
/** Why the player was sent back to the server list, if they were. */
|
||||
public get lastRejectionReason(): JoinRejectionReason | undefined {
|
||||
return this.rejectionReason;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue