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';
|
import { Options } from './options';
|
||||||
|
|
||||||
|
const isDevelopment = process.env.NODE_ENV !== 'production';
|
||||||
|
|
||||||
export const defaultOptions: Options = {
|
export const defaultOptions: Options = {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
name: 'Test server',
|
name: isDevelopment ? 'Dev server' : 'Doppler server',
|
||||||
playerLimit: 16,
|
playerLimit: 16,
|
||||||
npcCount: 8,
|
npcCount: 8,
|
||||||
seed: Math.random(),
|
seed: Math.random(),
|
||||||
|
|
|
||||||
|
|
@ -14,17 +14,27 @@ import {
|
||||||
CommandReceiver,
|
CommandReceiver,
|
||||||
CommandExecutors,
|
CommandExecutors,
|
||||||
ServerAnnouncement,
|
ServerAnnouncement,
|
||||||
|
JoinRejectionReason,
|
||||||
|
beginPropertyUpdateGeneration,
|
||||||
} from 'shared';
|
} from 'shared';
|
||||||
import { createWorld } from './create-world';
|
import { createWorld } from './create-world';
|
||||||
import { DeltaTimeCalculator } from './helper/delta-time-calculator';
|
import { DeltaTimeCalculator } from './helper/delta-time-calculator';
|
||||||
import { Options } from './options';
|
import { Options } from './options';
|
||||||
import { PlayerContainer } from './players/player-container';
|
import { PlayerContainer } from './players/player-container';
|
||||||
|
import { ServerFullError } from './players/server-full-error';
|
||||||
|
import { Player } from './players/player';
|
||||||
import { StepCommand } from './commands/step';
|
import { StepCommand } from './commands/step';
|
||||||
import { GeneratePointsCommand } from './commands/generate-points';
|
import { GeneratePointsCommand } from './commands/generate-points';
|
||||||
import { AnnounceCommand } from './commands/announce';
|
import { AnnounceCommand } from './commands/announce';
|
||||||
|
|
||||||
const gameStateSubscribedRoom = 'gameStateSubscribedRoom';
|
const gameStateSubscribedRoom = 'gameStateSubscribedRoom';
|
||||||
|
|
||||||
|
interface JoinedSocket {
|
||||||
|
player: Player;
|
||||||
|
onPlayerToServer: (json: string) => void;
|
||||||
|
onDisconnect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
export class GameServer extends CommandReceiver {
|
export class GameServer extends CommandReceiver {
|
||||||
private objects!: PhysicalContainer;
|
private objects!: PhysicalContainer;
|
||||||
private players!: PlayerContainer;
|
private players!: PlayerContainer;
|
||||||
|
|
@ -43,6 +53,7 @@ export class GameServer extends CommandReceiver {
|
||||||
|
|
||||||
private initialize() {
|
private initialize() {
|
||||||
const previousPlayers = this.players;
|
const previousPlayers = this.players;
|
||||||
|
this.releaseJoinedSockets();
|
||||||
this.objects = new PhysicalContainer();
|
this.objects = new PhysicalContainer();
|
||||||
createWorld(this.objects);
|
createWorld(this.objects);
|
||||||
this.objects.initialize();
|
this.objects.initialize();
|
||||||
|
|
@ -80,30 +91,9 @@ export class GameServer extends CommandReceiver {
|
||||||
this.initialize();
|
this.initialize();
|
||||||
|
|
||||||
io.on('connection', (socket: Socket) => {
|
io.on('connection', (socket: Socket) => {
|
||||||
socket.on(TransportEvents.PlayerJoining, (playerInfo: PlayerInformation) => {
|
socket.on(TransportEvents.PlayerJoining, (playerInfo: PlayerInformation) =>
|
||||||
try {
|
this.handleJoin(socket, playerInfo),
|
||||||
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.SubscribeForServerInfoUpdates, () => {
|
socket.on(TransportEvents.SubscribeForServerInfoUpdates, () => {
|
||||||
socket.join(gameStateSubscribedRoom);
|
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;
|
private timeSinceLastServerStateUpdate = 0;
|
||||||
public sendServerStateUpdate() {
|
public sendServerStateUpdate() {
|
||||||
this.io
|
this.io
|
||||||
|
|
@ -225,6 +342,7 @@ export class GameServer extends CommandReceiver {
|
||||||
this.players.step(scaledDelta);
|
this.players.step(scaledDelta);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
beginPropertyUpdateGeneration();
|
||||||
this.players.stepCommunication(delta);
|
this.players.stepCommunication(delta);
|
||||||
this.objects.resetRemoteCalls();
|
this.objects.resetRemoteCalls();
|
||||||
|
|
||||||
|
|
@ -263,6 +381,14 @@ export class GameServer extends CommandReceiver {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
if (this.saturatedFrames > 0) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`Physics saturated on ${this.saturatedFrames} frame(s) since last report — shedding backlog`,
|
`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 express from 'express';
|
||||||
import { Server } from 'http';
|
import { Server } from 'http';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import { applyArrayPlugins, Random, serverInformationEndpoint } from 'shared';
|
import { applyArrayPlugins, Random, serverInformationEndpoint, settings } from 'shared';
|
||||||
import minimist from 'minimist';
|
import minimist from 'minimist';
|
||||||
import { glMatrix } from 'gl-matrix';
|
import { glMatrix } from 'gl-matrix';
|
||||||
import { GameServer } from './game-server';
|
import { GameServer } from './game-server';
|
||||||
|
|
@ -24,6 +24,7 @@ const app = express();
|
||||||
const server = new Server(app);
|
const server = new Server(app);
|
||||||
const io = new IoServer(server, {
|
const io = new IoServer(server, {
|
||||||
parser,
|
parser,
|
||||||
|
maxHttpBufferSize: settings.maxInboundMessageBytes,
|
||||||
cors: {
|
cors: {
|
||||||
origin: true,
|
origin: true,
|
||||||
credentials: true,
|
credentials: true,
|
||||||
|
|
|
||||||
|
|
@ -171,17 +171,25 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private hasFiredSinceSpawn = false;
|
||||||
private get isSpawnProtected(): boolean {
|
private get isSpawnProtected(): boolean {
|
||||||
return (
|
return (
|
||||||
|
!this.hasFiredSinceSpawn &&
|
||||||
this.timeAlive <
|
this.timeAlive <
|
||||||
settings.spawnDespawnTime + settings.spawnInvulnerabilityExtraSeconds
|
settings.spawnDespawnTime + settings.spawnInvulnerabilityExtraSeconds
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private hasGeneratedPoints = false;
|
private hasGeneratedPoints = false;
|
||||||
|
private wasKilledInCombat = false;
|
||||||
private getPoints(game: CommandReceiver) {
|
private getPoints(game: CommandReceiver) {
|
||||||
if (!this.isAlive && !this.hasGeneratedPoints) {
|
if (!this.isAlive && !this.hasGeneratedPoints) {
|
||||||
this.hasGeneratedPoints = true;
|
this.hasGeneratedPoints = true;
|
||||||
|
|
||||||
|
if (!this.wasKilledInCombat) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const blue = this.team === CharacterTeam.blue ? 0 : settings.playerKillPoint;
|
const blue = this.team === CharacterTeam.blue ? 0 : settings.playerKillPoint;
|
||||||
const red = this.team === CharacterTeam.red ? 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,
|
other.direction,
|
||||||
mix(settings.deathImpulseMin, settings.deathImpulseMax, other.charge),
|
mix(settings.deathImpulseMin, settings.deathImpulseMax, other.charge),
|
||||||
);
|
);
|
||||||
this.onDie();
|
this.onDie(true);
|
||||||
other.originator.addKill(this.name, other.charge);
|
other.originator.addKill(this.name, other.charge);
|
||||||
} else {
|
} else {
|
||||||
other.originator.registerHit(other.charge);
|
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 (
|
if (
|
||||||
!this.isAlive ||
|
!this.isAlive ||
|
||||||
this.timeSinceLastShot < settings.projectileCreationInterval ||
|
this.timeSinceLastShot < settings.projectileCreationInterval ||
|
||||||
|
|
@ -277,21 +285,31 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
return;
|
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(
|
const desiredStrength = mix(
|
||||||
settings.chargeShotStrengthMin,
|
settings.chargeShotStrengthMin,
|
||||||
settings.chargeShotStrengthMax,
|
settings.chargeShotStrengthMax,
|
||||||
c,
|
requestedCharge,
|
||||||
);
|
);
|
||||||
const strength = Math.min(desiredStrength, this.projectileStrength);
|
const strength = Math.min(desiredStrength, this.projectileStrength);
|
||||||
this.projectileStrength -= strength;
|
this.projectileStrength -= strength;
|
||||||
|
|
||||||
|
const c = clamp01(
|
||||||
|
(strength - settings.chargeShotStrengthMin) /
|
||||||
|
(settings.chargeShotStrengthMax - settings.chargeShotStrengthMin),
|
||||||
|
);
|
||||||
|
|
||||||
const radius = mix(settings.chargeShotRadiusMin, settings.chargeShotRadiusMax, c);
|
const radius = mix(settings.chargeShotRadiusMin, settings.chargeShotRadiusMax, c);
|
||||||
const speed = mix(settings.chargeShotSpeedMin, settings.chargeShotSpeedMax, c);
|
const speed = mix(settings.chargeShotSpeedMin, settings.chargeShotSpeedMax, c);
|
||||||
|
|
||||||
const direction = vec2.subtract(vec2.create(), position, this.center);
|
|
||||||
vec2.normalize(direction, direction);
|
vec2.normalize(direction, direction);
|
||||||
// Keep the unit direction before vec2.scale repurposes it as the velocity.
|
// Keep the unit direction before vec2.scale repurposes it as the velocity.
|
||||||
const shotDirection = vec2.clone(direction);
|
const shotDirection = vec2.clone(direction);
|
||||||
|
|
@ -307,6 +325,10 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
c,
|
c,
|
||||||
);
|
);
|
||||||
this.container.addObject(projectile);
|
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) {
|
if (c > 0) {
|
||||||
vec2.scaleAndAdd(
|
vec2.scaleAndAdd(
|
||||||
|
|
@ -435,8 +457,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
),
|
),
|
||||||
(this.rightFoot.radius - oldRightFoot.radius) / deltaTime,
|
(this.rightFoot.radius - oldRightFoot.radius) / deltaTime,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.animateScaling(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private step({ deltaTimeInSeconds, game }: StepCommand) {
|
private step({ deltaTimeInSeconds, game }: StepCommand) {
|
||||||
|
|
@ -467,6 +487,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
if (this.hasJustBorn) {
|
if (this.hasJustBorn) {
|
||||||
if ((this.timeSinceBorn += deltaTimeInSeconds) > settings.spawnDespawnTime) {
|
if ((this.timeSinceBorn += deltaTimeInSeconds) > settings.spawnDespawnTime) {
|
||||||
this.hasJustBorn = false;
|
this.hasJustBorn = false;
|
||||||
|
this.animateScaling(1);
|
||||||
} else {
|
} else {
|
||||||
this.animateScaling(this.timeSinceBorn / settings.spawnDespawnTime);
|
this.animateScaling(this.timeSinceBorn / settings.spawnDespawnTime);
|
||||||
}
|
}
|
||||||
|
|
@ -550,7 +571,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public onDie() {
|
public onDie(killedInCombat = false) {
|
||||||
|
this.wasKilledInCombat = killedInCombat;
|
||||||
this.isDestroyed = true;
|
this.isDestroyed = true;
|
||||||
this.remoteCall('onDie');
|
this.remoteCall('onDie');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this._boundingBox = new BoundingBox();
|
this._boundingBox = new BoundingBox();
|
||||||
this.recalculateBoundingBox();
|
this.syncBoundingBox();
|
||||||
}
|
}
|
||||||
|
|
||||||
public get boundingBox(): BoundingBoxBase {
|
public get boundingBox(): BoundingBoxBase {
|
||||||
|
|
@ -52,7 +52,7 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh
|
||||||
|
|
||||||
public set center(value: vec2) {
|
public set center(value: vec2) {
|
||||||
this._center = value;
|
this._center = value;
|
||||||
this.recalculateBoundingBox();
|
this.syncBoundingBox();
|
||||||
}
|
}
|
||||||
|
|
||||||
public onCollision(c: ReactToCollisionCommand) {
|
public onCollision(c: ReactToCollisionCommand) {
|
||||||
|
|
@ -69,14 +69,14 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh
|
||||||
|
|
||||||
public set radius(value: number) {
|
public set radius(value: number) {
|
||||||
this._radius = value;
|
this._radius = value;
|
||||||
this.recalculateBoundingBox();
|
this.syncBoundingBox();
|
||||||
}
|
}
|
||||||
|
|
||||||
public distance(target: vec2): number {
|
public distance(target: vec2): number {
|
||||||
return vec2.distance(target, this.center) - this.radius;
|
return vec2.distance(target, this.center) - this.radius;
|
||||||
}
|
}
|
||||||
|
|
||||||
private recalculateBoundingBox() {
|
public syncBoundingBox() {
|
||||||
this._boundingBox.xMin = this.center.x - this._radius;
|
this._boundingBox.xMin = this.center.x - this._radius;
|
||||||
this._boundingBox.xMax = this.center.x + this._radius;
|
this._boundingBox.xMax = this.center.x + this._radius;
|
||||||
this._boundingBox.yMin = this.center.y - 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 };
|
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) {
|
public queueSetLight(color: vec3, lightness: number) {
|
||||||
|
this.color = vec3.clone(color);
|
||||||
|
this.lightness = lightness;
|
||||||
this.remoteCall('setLight', color, lightness);
|
this.remoteCall('setLight', color, lightness);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
private detectFlip(game: CommandReceiver) {
|
||||||
const currentTeam = this.team;
|
const currentTeam = this.flipTeam;
|
||||||
if (currentTeam === this.lastTeam) {
|
if (currentTeam === this.lastTeam) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,15 +73,38 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
|
||||||
vec2.normalize(vec2.create(), this.velocity),
|
vec2.normalize(vec2.create(), this.velocity),
|
||||||
10,
|
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
|
const intersecting = this.container
|
||||||
.findIntersecting(this.boundingBox)
|
.findIntersecting(this.boundingBox)
|
||||||
.filter((g) => g instanceof CharacterPhysical && g.team === this.team);
|
.filter((g) => g instanceof CharacterPhysical && g.team === this.team);
|
||||||
const { hitSurface } = marchCircle(this.object, delta, intersecting, true);
|
const { hitSurface } = marchCircle(this.object, delta, intersecting, true);
|
||||||
wasCollision = hitSurface;
|
wasCollision = hitSurface;
|
||||||
|
this.object.syncBoundingBox();
|
||||||
}
|
}
|
||||||
vec2.add(this.center, this.center, delta);
|
vec2.add(this.center, this.center, delta);
|
||||||
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 {
|
public get boundingBox(): ImmutableBoundingBox {
|
||||||
|
|
@ -123,6 +146,10 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleStep({ deltaTimeInSeconds }: StepCommand) {
|
private handleStep({ deltaTimeInSeconds }: StepCommand) {
|
||||||
|
this.advance(deltaTimeInSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private advance(deltaTimeInSeconds: number) {
|
||||||
super.step(deltaTimeInSeconds);
|
super.step(deltaTimeInSeconds);
|
||||||
|
|
||||||
if (this.strength <= 0) {
|
if (this.strength <= 0) {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
CharacterTeam,
|
CharacterTeam,
|
||||||
Random,
|
Random,
|
||||||
settings,
|
settings,
|
||||||
|
sanitizeName,
|
||||||
} from 'shared';
|
} from 'shared';
|
||||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
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 { PlanetPhysical } from '../objects/planet-physical';
|
||||||
import { PlayerContainer } from './player-container';
|
import { PlayerContainer } from './player-container';
|
||||||
|
|
||||||
|
const maximumNameLength = 40;
|
||||||
|
|
||||||
export abstract class PlayerBase extends CommandReceiver {
|
export abstract class PlayerBase extends CommandReceiver {
|
||||||
public character?: CharacterPhysical | null;
|
public character?: CharacterPhysical | null;
|
||||||
public center: vec2 = vec2.create();
|
public center: vec2 = vec2.create();
|
||||||
|
|
@ -32,7 +35,12 @@ export abstract class PlayerBase extends CommandReceiver {
|
||||||
|
|
||||||
protected createCharacter() {
|
protected createCharacter() {
|
||||||
this.character = new CharacterPhysical(
|
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.sumKills,
|
||||||
this.sumDeaths,
|
this.sumDeaths,
|
||||||
this.team,
|
this.team,
|
||||||
|
|
@ -72,10 +80,17 @@ export abstract class PlayerBase extends CommandReceiver {
|
||||||
return vec2.clone(Random.choose(safe.length ? safe : candidates)!.center);
|
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 {
|
protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 {
|
||||||
let rotation = 0;
|
let rotation = 0;
|
||||||
let radius = 0;
|
let radius = 0;
|
||||||
for (;;) {
|
for (let attempt = 0; attempt < 512; attempt++) {
|
||||||
const playerPosition = vec2.fromValues(
|
const playerPosition = vec2.fromValues(
|
||||||
radius * Math.cos(rotation) + preferredCenter.x,
|
radius * Math.cos(rotation) + preferredCenter.x,
|
||||||
radius * Math.sin(rotation) + preferredCenter.y,
|
radius * Math.sin(rotation) + preferredCenter.y,
|
||||||
|
|
@ -96,6 +111,8 @@ export abstract class PlayerBase extends CommandReceiver {
|
||||||
rotation += Math.PI / 8;
|
rotation += Math.PI / 8;
|
||||||
radius += 30;
|
radius += 30;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return vec2.clone(preferredCenter);
|
||||||
}
|
}
|
||||||
|
|
||||||
public destroy() {
|
public destroy() {
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,22 @@ import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||||
import { NPC } from './npc';
|
import { NPC } from './npc';
|
||||||
import { Player } from './player';
|
import { Player } from './player';
|
||||||
import { PlayerBase } from './player-base';
|
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 {
|
export class PlayerContainer {
|
||||||
private _players: Array<Player> = [];
|
private _players: Array<Player> = [];
|
||||||
private _npcs: Array<NPC> = [];
|
private _npcs: Array<NPC> = [];
|
||||||
|
private reservedScores = new Map<string, ReservedScore>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly objects: PhysicalContainer,
|
private readonly objects: PhysicalContainer,
|
||||||
|
|
@ -31,11 +43,15 @@ export class PlayerContainer {
|
||||||
}
|
}
|
||||||
|
|
||||||
public createPlayer(playerInfo: PlayerInformation, socket: Socket): Player {
|
public createPlayer(playerInfo: PlayerInformation, socket: Socket): Player {
|
||||||
if (this._players.length === this.playerMaxCount) {
|
if (this._players.length >= this.playerMaxCount) {
|
||||||
throw new Error('Too many players');
|
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);
|
let npcToReplace = this._npcs.find((n) => n.team === team);
|
||||||
if (!npcToReplace) {
|
if (!npcToReplace) {
|
||||||
npcToReplace = this._npcs.find((n) => n.team !== team);
|
npcToReplace = this._npcs.find((n) => n.team !== team);
|
||||||
|
|
@ -43,12 +59,63 @@ export class PlayerContainer {
|
||||||
npcToReplace?.destroy();
|
npcToReplace?.destroy();
|
||||||
this._npcs = this._npcs.filter((n) => n !== npcToReplace);
|
this._npcs = this._npcs.filter((n) => n !== npcToReplace);
|
||||||
|
|
||||||
const player = new Player(playerInfo, this, this.objects, team, socket);
|
|
||||||
this._players.push(player);
|
this._players.push(player);
|
||||||
|
|
||||||
|
if (reserved) {
|
||||||
|
player.restoreScore(reserved.kills, reserved.deaths);
|
||||||
|
}
|
||||||
|
|
||||||
return player;
|
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> {
|
public get players(): Array<PlayerBase> {
|
||||||
return [...this._players, ...this._npcs];
|
return [...this._players, ...this._npcs];
|
||||||
}
|
}
|
||||||
|
|
@ -57,6 +124,10 @@ export class PlayerContainer {
|
||||||
return this._players.length;
|
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
|
// Measured round-trip times (ms) of the real connected players, for
|
||||||
// server-side latency stats. NPCs have no socket and are excluded.
|
// server-side latency stats. NPCs have no socket and are excluded.
|
||||||
public get connectedPlayerRttsMs(): Array<number> {
|
public get connectedPlayerRttsMs(): Array<number> {
|
||||||
|
|
@ -96,7 +167,13 @@ export class PlayerContainer {
|
||||||
}
|
}
|
||||||
|
|
||||||
public deletePlayer(player: Player) {
|
public deletePlayer(player: Player) {
|
||||||
|
const had = this._players.includes(player);
|
||||||
this._players = this._players.filter((p) => p !== player);
|
this._players = this._players.filter((p) => p !== player);
|
||||||
|
// 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();
|
this.createNPCs();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,16 +25,30 @@ import {
|
||||||
PrimaryActionCommand,
|
PrimaryActionCommand,
|
||||||
LeapActionCommand,
|
LeapActionCommand,
|
||||||
InputAcknowledgement,
|
InputAcknowledgement,
|
||||||
|
ClientHeartbeatCommand,
|
||||||
|
finiteInRange,
|
||||||
|
finiteVec2,
|
||||||
|
isFiniteNumber,
|
||||||
} from 'shared';
|
} from 'shared';
|
||||||
import { Socket } from 'socket.io';
|
import { Socket } from 'socket.io';
|
||||||
import { BoundingBox } from '../physics/bounding-boxes/bounding-box';
|
import { BoundingBox } from '../physics/bounding-boxes/bounding-box';
|
||||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||||
import { PlayerContainer } from './player-container';
|
import { PlayerContainer } from './player-container';
|
||||||
import { PlayerBase } from './player-base';
|
import { PlayerBase } from './player-base';
|
||||||
|
import { CharacterPhysical } from '../objects/character-physical';
|
||||||
|
|
||||||
// How often the server pings each client to measure round-trip time.
|
// How often the server pings each client to measure round-trip time.
|
||||||
const pingIntervalSeconds = 1;
|
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 {
|
export class Player extends PlayerBase {
|
||||||
// default, until the clients sends its real value
|
// default, until the clients sends its real value
|
||||||
private aspectRatio: number = 16 / 9;
|
private aspectRatio: number = 16 / 9;
|
||||||
|
|
@ -42,34 +56,98 @@ export class Player extends PlayerBase {
|
||||||
private timeSinceLastMessage = 0;
|
private timeSinceLastMessage = 0;
|
||||||
private objectsPreviouslyInViewArea: Array<GameObject> = [];
|
private objectsPreviouslyInViewArea: Array<GameObject> = [];
|
||||||
private lastInputClientTimeMs = 0;
|
private lastInputClientTimeMs = 0;
|
||||||
|
private lastInputReceiptMs = 0;
|
||||||
private lastLeapClientTimeMs = 0;
|
private lastLeapClientTimeMs = 0;
|
||||||
|
|
||||||
// Measured round-trip time to this client (ms) — the latency primitive that
|
// Token this session was issued at join. On a drop it is what the held score
|
||||||
// lag compensation, a latency HUD, and adaptive interpolation build on.
|
// 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;
|
public rttMs = 0;
|
||||||
private timeSinceLastPing = 0;
|
private timeSinceLastPing = 0;
|
||||||
private lastPingSentMs = 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 = {
|
protected commandExecutors: CommandExecutors = {
|
||||||
[SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) =>
|
[SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) => {
|
||||||
(this.aspectRatio = v.aspectRatio),
|
this.aspectRatio = finiteInRange(
|
||||||
|
v.aspectRatio,
|
||||||
|
minimumAspectRatio,
|
||||||
|
maximumAspectRatio,
|
||||||
|
this.aspectRatio,
|
||||||
|
);
|
||||||
|
},
|
||||||
[MoveActionCommand.type]: (c: MoveActionCommand) => {
|
[MoveActionCommand.type]: (c: MoveActionCommand) => {
|
||||||
// Remember how far into this client's input timeline we've consumed, to
|
const direction = finiteVec2(c.direction, 1);
|
||||||
// echo back for client-side prediction reconciliation.
|
if (!direction) {
|
||||||
this.lastInputClientTimeMs = c.clientTimeMs;
|
return;
|
||||||
this.character?.handleMovementAction(c);
|
}
|
||||||
|
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) => {
|
[LeapActionCommand.type]: (c: LeapActionCommand) => {
|
||||||
|
if (!isFiniteNumber(c.clientTimeMs)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Record receipt (whether or not leap() accepts it): either way its effect
|
// Record receipt (whether or not leap() accepts it): either way its effect
|
||||||
// on bodyVelocity is now reflected in the streamed launch momentum, so the
|
// on bodyVelocity is now reflected in the streamed launch momentum, so the
|
||||||
// predictor must stop replaying this leap.
|
// predictor must stop replaying this leap.
|
||||||
this.lastLeapClientTimeMs = c.clientTimeMs;
|
this.lastLeapClientTimeMs = c.clientTimeMs;
|
||||||
|
this.observeClientTime(c.clientTimeMs);
|
||||||
this.character?.leap();
|
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(
|
constructor(
|
||||||
playerInfo: PlayerInformation,
|
playerInfo: PlayerInformation,
|
||||||
playerContainer: PlayerContainer,
|
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
|
// 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
|
// ping is ever in flight, so RTT is simply now − send-time; no payload
|
||||||
// needed and no client change required.
|
// needed and no client change required.
|
||||||
this.socket.on(TransportEvents.Pong, () => {
|
this.socket.on(TransportEvents.Pong, this.onPong);
|
||||||
if (this.lastPingSentMs > 0) {
|
|
||||||
this.rttMs = performance.now() - this.lastPingSentMs;
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
// 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() {
|
protected createCharacter() {
|
||||||
|
|
@ -103,6 +199,11 @@ export class Player extends PlayerBase {
|
||||||
this.winnerTeam = winnerTeam;
|
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) {
|
public step(deltaTimeInSeconds: number) {
|
||||||
if (this.character) {
|
if (this.character) {
|
||||||
this.center = this.character?.center;
|
this.center = this.character?.center;
|
||||||
|
|
@ -111,11 +212,16 @@ export class Player extends PlayerBase {
|
||||||
this.sumDeaths++;
|
this.sumDeaths++;
|
||||||
this.sumKills = this.character.killCount;
|
this.sumKills = this.character.killCount;
|
||||||
|
|
||||||
|
this.dyingCharacter = this.character;
|
||||||
this.character = null;
|
this.character = null;
|
||||||
this.timeUntilRespawn = settings.playerDiedTimeout;
|
this.timeUntilRespawn = settings.playerDiedTimeout;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) {
|
if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) {
|
||||||
|
if (this.dyingCharacter) {
|
||||||
|
this.sumKills = Math.max(this.sumKills, this.dyingCharacter.killCount);
|
||||||
|
this.dyingCharacter = null;
|
||||||
|
}
|
||||||
this.createCharacter();
|
this.createCharacter();
|
||||||
this.center = this.character!.center;
|
this.center = this.character!.center;
|
||||||
}
|
}
|
||||||
|
|
@ -164,7 +270,7 @@ export class Player extends PlayerBase {
|
||||||
this.queueCommandSend(
|
this.queueCommandSend(
|
||||||
new PropertyUpdatesForObjects(
|
new PropertyUpdatesForObjects(
|
||||||
this.objectsPreviouslyInViewArea
|
this.objectsPreviouslyInViewArea
|
||||||
.map((o) => o.getPropertyUpdates())
|
.map((o) => o.getPropertyUpdatesForFrame())
|
||||||
.filter((u) => u) as Array<PropertyUpdatesForObject>,
|
.filter((u) => u) as Array<PropertyUpdatesForObject>,
|
||||||
performance.now() / 1000,
|
performance.now() / 1000,
|
||||||
),
|
),
|
||||||
|
|
@ -179,6 +285,9 @@ export class Player extends PlayerBase {
|
||||||
this.lastInputClientTimeMs,
|
this.lastInputClientTimeMs,
|
||||||
this.character.launchMomentum,
|
this.character.launchMomentum,
|
||||||
this.lastLeapClientTimeMs,
|
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) {
|
if ((this.timeSinceLastPing += deltaTime) > pingIntervalSeconds) {
|
||||||
this.timeSinceLastPing = 0;
|
this.timeSinceLastPing = 0;
|
||||||
this.lastPingSentMs = performance.now();
|
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) {
|
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() {
|
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.socket.emit(TransportEvents.ServerToPlayer, serialize(this.commandsToBeSent));
|
||||||
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() {
|
private handleAnnouncements() {
|
||||||
let announcement = '';
|
let announcement = '';
|
||||||
if (this.winnerTeam) {
|
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 { glMatrix } from 'gl-matrix';
|
||||||
import {
|
import {
|
||||||
|
applyArrayPlugins,
|
||||||
LampBase,
|
LampBase,
|
||||||
overrideDeserialization,
|
overrideDeserialization,
|
||||||
PlanetBase,
|
PlanetBase,
|
||||||
|
|
@ -29,6 +30,7 @@ import { PlanetView } from './scripts/objects/types/planet-view';
|
||||||
import { ProjectileView } from './scripts/objects/types/projectile-view';
|
import { ProjectileView } from './scripts/objects/types/projectile-view';
|
||||||
|
|
||||||
glMatrix.setMatrixArrayType(Array);
|
glMatrix.setMatrixArrayType(Array);
|
||||||
|
applyArrayPlugins();
|
||||||
|
|
||||||
overrideDeserialization(CharacterBase, CharacterView);
|
overrideDeserialization(CharacterBase, CharacterView);
|
||||||
overrideDeserialization(PlanetBase, PlanetView);
|
overrideDeserialization(PlanetBase, PlanetView);
|
||||||
|
|
@ -53,6 +55,11 @@ const enableMusic = document.querySelector('#enable-music') as HTMLInputElement;
|
||||||
const enableVibration = document.querySelector('#enable-vibration') as HTMLInputElement;
|
const enableVibration = document.querySelector('#enable-vibration') as HTMLInputElement;
|
||||||
const spinner = document.querySelector('#spinner-container') as HTMLElement;
|
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 = () => {
|
const toggleSettings = () => {
|
||||||
settings.className = settings.className === 'open' ? '' : 'open';
|
settings.className = settings.className === 'open' ? '' : 'open';
|
||||||
SoundHandler.play(Sounds.click);
|
SoundHandler.play(Sounds.click);
|
||||||
|
|
@ -122,7 +129,7 @@ const main = async () => {
|
||||||
});
|
});
|
||||||
window.onpopstate = () => game.destroy();
|
window.onpopstate = () => game.destroy();
|
||||||
|
|
||||||
for (;;) {
|
for (; ;) {
|
||||||
show(spinner);
|
show(spinner);
|
||||||
hide(logoutButton, true);
|
hide(logoutButton, true);
|
||||||
show(landingUI, true, 'flex');
|
show(landingUI, true, 'flex');
|
||||||
|
|
@ -150,6 +157,14 @@ const main = async () => {
|
||||||
hide(spinner);
|
hide(spinner);
|
||||||
show(logoutButton, true, 'block');
|
show(logoutButton, true, 'block');
|
||||||
await gameOver;
|
await gameOver;
|
||||||
|
|
||||||
|
const reason = game.lastRejectionReason;
|
||||||
|
if (reason) {
|
||||||
|
joinNotice.innerText = Game.rejectionText(reason);
|
||||||
|
joinNotice.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
joinNotice.style.display = 'none';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
|
|
||||||
|
|
@ -247,8 +247,7 @@ body {
|
||||||
|
|
||||||
border-radius: 1000px;
|
border-radius: 1000px;
|
||||||
background-color: rgba(0, 0, 0, 0.35);
|
background-color: rgba(0, 0, 0, 0.35);
|
||||||
box-shadow:
|
box-shadow: inset 0 0 3px 0 rgba(0, 0, 0, 0.4),
|
||||||
inset 0 0 3px 0 rgba(0, 0, 0, 0.4),
|
|
||||||
0 0 10px rgba(0, 0, 0, 0.3);
|
0 0 10px rgba(0, 0, 0, 0.3);
|
||||||
border: $border-width solid rgba(255, 255, 255, 0.25);
|
border: $border-width solid rgba(255, 255, 255, 0.25);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
|
@ -938,3 +937,28 @@ body {
|
||||||
transform: translate(-50%, -50%) scale(1);
|
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 { 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 {
|
export class CommandSocket extends CommandReceiver {
|
||||||
constructor(private readonly socket: Socket) {
|
constructor(private readonly socket: Socket) {
|
||||||
|
|
@ -7,14 +23,35 @@ export class CommandSocket extends CommandReceiver {
|
||||||
}
|
}
|
||||||
|
|
||||||
private commandQueue: Array<Command> = [];
|
private commandQueue: Array<Command> = [];
|
||||||
|
private lastSendMs = -Infinity;
|
||||||
|
|
||||||
protected defaultCommandExecutor(command: Command) {
|
protected defaultCommandExecutor(command: Command) {
|
||||||
this.commandQueue.push(command);
|
this.commandQueue.push(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sendQueuedCommands() {
|
public sendQueuedCommands() {
|
||||||
if (this.commandQueue.length > 0) {
|
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.socket.emit(TransportEvents.PlayerToServer, serialize(this.commandQueue));
|
||||||
this.commandQueue = [];
|
this.commandQueue = [];
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,20 +14,26 @@ export class KeyboardListener extends CommandGenerator {
|
||||||
}
|
}
|
||||||
|
|
||||||
private keyDownListener = (event: KeyboardEvent) => {
|
private keyDownListener = (event: KeyboardEvent) => {
|
||||||
const key = event.key.toLowerCase();
|
const key = event.code;
|
||||||
// Space leaps (W / ArrowUp already cover walking up). Edge-triggered so a
|
if (
|
||||||
// held key's auto-repeat doesn't spam leaps.
|
(key === 'Space' || key === 'ShiftLeft' || key === 'ShiftRight') &&
|
||||||
if ((key === ' ' || key === 'shift') && !this.keysDown.has(key)) {
|
!this.keysDown.has(key)
|
||||||
|
) {
|
||||||
const clientTimeMs = localCharacterPredictor.recordLeap();
|
const clientTimeMs = localCharacterPredictor.recordLeap();
|
||||||
this.sendCommandToSubscribers(new LeapActionCommand(clientTimeMs));
|
this.sendCommandToSubscribers(new LeapActionCommand(clientTimeMs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.keysDown.has(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.keysDown.add(key);
|
this.keysDown.add(key);
|
||||||
this.generateCommands();
|
this.generateCommands();
|
||||||
};
|
};
|
||||||
|
|
||||||
private keyUpListener = (event: KeyboardEvent) => {
|
private keyUpListener = (event: KeyboardEvent) => {
|
||||||
this.keysDown.delete(event.key.toLowerCase());
|
if (this.keysDown.delete(event.code)) {
|
||||||
this.generateCommands();
|
this.generateCommands();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private blurListener = () => {
|
private blurListener = () => {
|
||||||
|
|
@ -36,10 +42,10 @@ export class KeyboardListener extends CommandGenerator {
|
||||||
};
|
};
|
||||||
|
|
||||||
private generateCommands() {
|
private generateCommands() {
|
||||||
const up = ~~(this.keysDown.has('w') || this.keysDown.has('arrowup'));
|
const up = ~~(this.keysDown.has('KeyW') || this.keysDown.has('ArrowUp'));
|
||||||
const down = ~~(this.keysDown.has('s') || this.keysDown.has('arrowdown'));
|
const down = ~~(this.keysDown.has('KeyS') || this.keysDown.has('ArrowDown'));
|
||||||
const left = ~~(this.keysDown.has('a') || this.keysDown.has('arrowleft'));
|
const left = ~~(this.keysDown.has('KeyA') || this.keysDown.has('ArrowLeft'));
|
||||||
const right = ~~(this.keysDown.has('d') || this.keysDown.has('arrowright'));
|
const right = ~~(this.keysDown.has('KeyD') || this.keysDown.has('ArrowRight'));
|
||||||
|
|
||||||
const movement = vec2.fromValues(right - left, up - down);
|
const movement = vec2.fromValues(right - left, up - down);
|
||||||
if (vec2.squaredLength(movement) > 0) {
|
if (vec2.squaredLength(movement) > 0) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { vec2 } from 'gl-matrix';
|
||||||
import { CommandGenerator, PrimaryActionCommand, holdDurationToCharge } from 'shared';
|
import { CommandGenerator, PrimaryActionCommand, holdDurationToCharge } from 'shared';
|
||||||
import { Game } from '../game';
|
import { Game } from '../game';
|
||||||
import { ChargeIndicator } from '../charge-indicator';
|
import { ChargeIndicator } from '../charge-indicator';
|
||||||
|
import { predictorNowMs } from '../helper/prediction/local-character-predictor';
|
||||||
import { Pointer } from '../helper/pointer';
|
import { Pointer } from '../helper/pointer';
|
||||||
|
|
||||||
export class MouseListener extends CommandGenerator {
|
export class MouseListener extends CommandGenerator {
|
||||||
|
|
@ -16,9 +17,13 @@ export class MouseListener extends CommandGenerator {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
target.addEventListener('mousedown', this.mouseDownListener);
|
target.addEventListener('mousedown', this.mouseDownListener);
|
||||||
target.addEventListener('mouseup', this.mouseUpListener);
|
|
||||||
target.addEventListener('mousemove', this.mouseMoveListener);
|
target.addEventListener('mousemove', this.mouseMoveListener);
|
||||||
target.addEventListener('contextmenu', this.contextMenuListener);
|
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
|
// 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);
|
const charge = holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000);
|
||||||
this.primaryDownAt = null;
|
this.primaryDownAt = null;
|
||||||
this.sendCommandToSubscribers(
|
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.
|
// Suppress the browser context menu on the canvas; right-click has no action.
|
||||||
private contextMenuListener = (event: MouseEvent) => {
|
private contextMenuListener = (event: MouseEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
@ -64,8 +82,9 @@ export class MouseListener extends CommandGenerator {
|
||||||
public destroy() {
|
public destroy() {
|
||||||
ChargeIndicator.end();
|
ChargeIndicator.end();
|
||||||
this.target.removeEventListener('mousedown', this.mouseDownListener);
|
this.target.removeEventListener('mousedown', this.mouseDownListener);
|
||||||
this.target.removeEventListener('mouseup', this.mouseUpListener);
|
|
||||||
this.target.removeEventListener('mousemove', this.mouseMoveListener);
|
this.target.removeEventListener('mousemove', this.mouseMoveListener);
|
||||||
this.target.removeEventListener('contextmenu', this.contextMenuListener);
|
this.target.removeEventListener('contextmenu', this.contextMenuListener);
|
||||||
|
window.removeEventListener('mouseup', this.mouseUpListener);
|
||||||
|
window.removeEventListener('blur', this.cancelPrimary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,10 @@ import {
|
||||||
} from 'shared';
|
} from 'shared';
|
||||||
import { Game } from '../game';
|
import { Game } from '../game';
|
||||||
import { ChargeIndicator } from '../charge-indicator';
|
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 {
|
export class TouchListener extends CommandGenerator {
|
||||||
private static readonly deadZone = 8;
|
private static readonly deadZone = 8;
|
||||||
|
|
@ -24,6 +27,7 @@ export class TouchListener extends CommandGenerator {
|
||||||
private isJoystickActive = false;
|
private isJoystickActive = false;
|
||||||
private touchStartPosition!: vec2;
|
private touchStartPosition!: vec2;
|
||||||
private primaryDownAt: number | null = null;
|
private primaryDownAt: number | null = null;
|
||||||
|
private gestureTouchId: number | null = null;
|
||||||
|
|
||||||
private fireButton: HTMLElement;
|
private fireButton: HTMLElement;
|
||||||
private fireStrengthRing: HTMLElement;
|
private fireStrengthRing: HTMLElement;
|
||||||
|
|
@ -69,23 +73,43 @@ export class TouchListener extends CommandGenerator {
|
||||||
target.addEventListener('touchstart', this.touchStartListener);
|
target.addEventListener('touchstart', this.touchStartListener);
|
||||||
target.addEventListener('touchmove', this.touchMoveListener);
|
target.addEventListener('touchmove', this.touchMoveListener);
|
||||||
target.addEventListener('touchend', this.touchEndListener);
|
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) => {
|
private touchStartListener = (event: TouchEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (this.isJoystickActive) {
|
if (this.isJoystickActive) {
|
||||||
const center = vec2.fromValues(
|
const center = vec2.fromValues(
|
||||||
last(event.touches)!.clientX,
|
last(event.changedTouches)!.clientX,
|
||||||
last(event.touches)!.clientY,
|
last(event.changedTouches)!.clientY,
|
||||||
);
|
);
|
||||||
this.sendCommandToSubscribers(
|
this.sendCommandToSubscribers(
|
||||||
new PrimaryActionCommand(this.game.displayToWorldCoordinates(center)),
|
new PrimaryActionCommand(
|
||||||
|
this.game.displayToWorldCoordinates(center),
|
||||||
|
0,
|
||||||
|
Math.round(predictorNowMs()),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.touchStartPosition = vec2.fromValues(
|
const touch = event.changedTouches[0];
|
||||||
event.touches[0].clientX,
|
if (!touch) {
|
||||||
event.touches[0].clientY,
|
return;
|
||||||
);
|
}
|
||||||
|
this.gestureTouchId = touch.identifier;
|
||||||
|
this.touchStartPosition = vec2.fromValues(touch.clientX, touch.clientY);
|
||||||
this.primaryDownAt = performance.now();
|
this.primaryDownAt = performance.now();
|
||||||
ChargeIndicator.begin(this.touchStartPosition.x, this.touchStartPosition.y);
|
ChargeIndicator.begin(this.touchStartPosition.x, this.touchStartPosition.y);
|
||||||
}
|
}
|
||||||
|
|
@ -94,10 +118,12 @@ export class TouchListener extends CommandGenerator {
|
||||||
private touchMoveListener = (event: TouchEvent) => {
|
private touchMoveListener = (event: TouchEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const touchPosition = vec2.fromValues(
|
const touch = this.findGestureTouch(event.touches);
|
||||||
event.touches[0].clientX,
|
if (!touch || !this.touchStartPosition) {
|
||||||
event.touches[0].clientY,
|
return;
|
||||||
);
|
}
|
||||||
|
|
||||||
|
const touchPosition = vec2.fromValues(touch.clientX, touch.clientY);
|
||||||
|
|
||||||
const delta = vec2.subtract(vec2.create(), touchPosition, this.touchStartPosition);
|
const delta = vec2.subtract(vec2.create(), touchPosition, this.touchStartPosition);
|
||||||
vec2.scale(delta, delta, TouchListener.deltaScaling);
|
vec2.scale(delta, delta, TouchListener.deltaScaling);
|
||||||
|
|
@ -133,6 +159,14 @@ export class TouchListener extends CommandGenerator {
|
||||||
private touchEndListener = (event: TouchEvent) => {
|
private touchEndListener = (event: TouchEvent) => {
|
||||||
event.preventDefault();
|
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) {
|
if (!this.isJoystickActive) {
|
||||||
ChargeIndicator.end();
|
ChargeIndicator.end();
|
||||||
const charge =
|
const charge =
|
||||||
|
|
@ -140,19 +174,38 @@ export class TouchListener extends CommandGenerator {
|
||||||
? 0
|
? 0
|
||||||
: holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000);
|
: holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000);
|
||||||
this.primaryDownAt = null;
|
this.primaryDownAt = null;
|
||||||
const center = vec2.fromValues(
|
const center = vec2.fromValues(touch.clientX, touch.clientY);
|
||||||
event.changedTouches[0].clientX,
|
|
||||||
event.changedTouches[0].clientY,
|
|
||||||
);
|
|
||||||
this.sendCommandToSubscribers(
|
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) {
|
} 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.isJoystickActive = false;
|
||||||
this.joystick.parentElement?.removeChild(this.joystick);
|
this.joystick.parentElement?.removeChild(this.joystick);
|
||||||
this.sendMove(vec2.create());
|
this.sendMove(vec2.create());
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
private swallowTouch = (event: TouchEvent) => {
|
private swallowTouch = (event: TouchEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
@ -237,7 +290,9 @@ export class TouchListener extends CommandGenerator {
|
||||||
direction,
|
direction,
|
||||||
settings.touchAimRange,
|
settings.touchAimRange,
|
||||||
);
|
);
|
||||||
this.sendCommandToSubscribers(new PrimaryActionCommand(aim, charge));
|
this.sendCommandToSubscribers(
|
||||||
|
new PrimaryActionCommand(aim, charge, Math.round(predictorNowMs())),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
public update(_deltaTimeInSeconds: number) {
|
public update(_deltaTimeInSeconds: number) {
|
||||||
|
|
@ -250,8 +305,7 @@ export class TouchListener extends CommandGenerator {
|
||||||
|
|
||||||
const character = this.game.gameObjects.player;
|
const character = this.game.gameObjects.player;
|
||||||
if (character) {
|
if (character) {
|
||||||
this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${
|
this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${character.strengthFraction * 360
|
||||||
character.strengthFraction * 360
|
|
||||||
}deg, transparent 0deg)`;
|
}deg, transparent 0deg)`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -261,6 +315,7 @@ export class TouchListener extends CommandGenerator {
|
||||||
this.target.removeEventListener('touchstart', this.touchStartListener);
|
this.target.removeEventListener('touchstart', this.touchStartListener);
|
||||||
this.target.removeEventListener('touchmove', this.touchMoveListener);
|
this.target.removeEventListener('touchmove', this.touchMoveListener);
|
||||||
this.target.removeEventListener('touchend', this.touchEndListener);
|
this.target.removeEventListener('touchend', this.touchEndListener);
|
||||||
|
this.target.removeEventListener('touchcancel', this.touchCancelListener);
|
||||||
|
|
||||||
this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener);
|
this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener);
|
||||||
this.fireButton.removeEventListener('touchmove', this.fireButtonMoveListener);
|
this.fireButton.removeEventListener('touchmove', this.fireButtonMoveListener);
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,18 @@ export abstract class FeedbackHud {
|
||||||
this.elimination = undefined;
|
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 {
|
private static streakName(streak: number): string | undefined {
|
||||||
switch (streak) {
|
switch (streak) {
|
||||||
case 2:
|
case 2:
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
Command,
|
Command,
|
||||||
settings,
|
settings,
|
||||||
InputAcknowledgement,
|
InputAcknowledgement,
|
||||||
|
JoinRejectionReason,
|
||||||
} from 'shared';
|
} from 'shared';
|
||||||
import { io, Socket } from 'socket.io-client';
|
import { io, Socket } from 'socket.io-client';
|
||||||
import { KeyboardListener } from './commands/keyboard-listener';
|
import { KeyboardListener } from './commands/keyboard-listener';
|
||||||
|
|
@ -40,6 +41,7 @@ import { Tutorial } from './tutorial';
|
||||||
import { Scoreboard } from './scoreboard';
|
import { Scoreboard } from './scoreboard';
|
||||||
import { Minimap } from './minimap';
|
import { Minimap } from './minimap';
|
||||||
import { ScreenShake } from './screen-shake';
|
import { ScreenShake } from './screen-shake';
|
||||||
|
import { FeedbackHud } from './feedback-hud';
|
||||||
|
|
||||||
export class Game extends CommandReceiver {
|
export class Game extends CommandReceiver {
|
||||||
public gameObjects = new GameObjectContainer(this);
|
public gameObjects = new GameObjectContainer(this);
|
||||||
|
|
@ -60,6 +62,11 @@ export class Game extends CommandReceiver {
|
||||||
private keystoneArrow?: HTMLElement;
|
private keystoneArrow?: HTMLElement;
|
||||||
private socketReceiver!: CommandSocket;
|
private socketReceiver!: CommandSocket;
|
||||||
private tutorial!: Tutorial;
|
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(
|
constructor(
|
||||||
private readonly playerDecision: PlayerDecision,
|
private readonly playerDecision: PlayerDecision,
|
||||||
|
|
@ -110,19 +117,76 @@ export class Game extends CommandReceiver {
|
||||||
this.socket.io.opts.transports = ['polling', 'websocket'];
|
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', () => {
|
this.socket.on('disconnect', () => {
|
||||||
if (!this.isBetweenGames) {
|
if (this.isBetweenGames) {
|
||||||
this.destroy();
|
return;
|
||||||
}
|
}
|
||||||
|
this.showConnectionBanner('Reconnecting…');
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.on(TransportEvents.Ping, () => {
|
this.socket.on('connect', () => {
|
||||||
this.socket.emit(TransportEvents.Pong);
|
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) => {
|
this.socket.on(TransportEvents.ServerToPlayer, (serializedCommands: string) => {
|
||||||
|
// 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);
|
const commands: Array<Command> = deserialize(serializedCommands);
|
||||||
commands.forEach((c) => this.handleCommand(c));
|
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);
|
this.socketReceiver = new CommandSocket(this.socket);
|
||||||
|
|
@ -138,9 +202,10 @@ export class Game extends CommandReceiver {
|
||||||
this.touchListener.subscribe(this.socketReceiver);
|
this.touchListener.subscribe(this.socketReceiver);
|
||||||
this.touchListener.subscribe(this.tutorial);
|
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.isBetweenGames = false;
|
||||||
|
|
||||||
this.socket.emit(TransportEvents.PlayerJoining, this.playerDecision);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected defaultCommandExecutor(c: Command) {
|
protected defaultCommandExecutor(c: Command) {
|
||||||
|
|
@ -163,6 +228,7 @@ export class Game extends CommandReceiver {
|
||||||
c.clientTimeMs,
|
c.clientTimeMs,
|
||||||
c.bodyVelocity,
|
c.bodyVelocity,
|
||||||
c.lastLeapClientTimeMs,
|
c.lastLeapClientTimeMs,
|
||||||
|
c.ackAgeMs,
|
||||||
),
|
),
|
||||||
[GameEndCommand.type]: () => (this.isEnding = true),
|
[GameEndCommand.type]: () => (this.isEnding = true),
|
||||||
[UpdateMinimap.type]: (c: UpdateMinimap) => (this.lastMinimap = c),
|
[UpdateMinimap.type]: (c: UpdateMinimap) => (this.lastMinimap = c),
|
||||||
|
|
@ -208,11 +274,49 @@ export class Game extends CommandReceiver {
|
||||||
);
|
);
|
||||||
this.socket.close();
|
this.socket.close();
|
||||||
this.overlay.innerHTML = '';
|
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.keyboardListener.destroy();
|
||||||
this.mouseListener.destroy();
|
this.mouseListener.destroy();
|
||||||
this.touchListener.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 {
|
public displayToWorldCoordinates(p: vec2): vec2 {
|
||||||
return this.renderer?.displayToWorldCoordinates(p) ?? vec2.create();
|
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