Improve netcode and simplify #4
124 changed files with 1004 additions and 2007 deletions
GLM
commit
5017ff168a
|
|
@ -1,9 +0,0 @@
|
|||
import { Command } from 'shared';
|
||||
|
||||
// Internal request from a game object (e.g. a keystone planet flipping) asking
|
||||
// the GameServer to broadcast a one-off announcement to every connected client.
|
||||
export class AnnounceCommand extends Command {
|
||||
public constructor(public readonly text: string) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
31
backend/src/commands/commands.ts
Normal file
31
backend/src/commands/commands.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { Command, CommandReceiver, GameObject } from 'shared';
|
||||
|
||||
export class StepCommand extends Command {
|
||||
constructor(
|
||||
public readonly deltaTimeInSeconds: number,
|
||||
public readonly game: CommandReceiver,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class GeneratePointsCommand extends Command {
|
||||
constructor(
|
||||
public readonly blue: number,
|
||||
public readonly red: number,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class AnnounceCommand extends Command {
|
||||
constructor(public readonly text: string) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class ReactToCollisionCommand extends Command {
|
||||
constructor(public readonly other: GameObject) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { Command } from 'shared';
|
||||
|
||||
export class GeneratePointsCommand extends Command {
|
||||
public constructor(
|
||||
public readonly blue: number,
|
||||
public readonly red: number,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { Command, GameObject } from 'shared';
|
||||
|
||||
export class ReactToCollisionCommand extends Command {
|
||||
public constructor(public readonly other: GameObject) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { Command, CommandReceiver } from 'shared';
|
||||
|
||||
export class StepCommand extends Command {
|
||||
public constructor(
|
||||
public readonly deltaTimeInSeconds: number,
|
||||
public readonly game: CommandReceiver,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
|
@ -44,9 +44,7 @@ export const createWorld = (objectContainer: PhysicalContainer) => {
|
|||
) {
|
||||
const planet =
|
||||
objects.length === 0
|
||||
? // The first, central giant is the keystone "Heart": a named,
|
||||
// always-contested focal objective the whole match orbits.
|
||||
new PlanetPhysical(
|
||||
? new PlanetPhysical(
|
||||
PlanetBase.createPlanetVertices(
|
||||
position,
|
||||
Random.getRandomInRange(1600, 2400),
|
||||
|
|
@ -72,11 +70,8 @@ export const createWorld = (objectContainer: PhysicalContainer) => {
|
|||
console.info(`Generated ${objects.length} planets`);
|
||||
console.info(`Generated ${lights.length} light`);
|
||||
|
||||
// Associate each lamp with its NEAREST planet, so a planet can repaint "its"
|
||||
// lamps to the owning team's colour when it flips. Lamps are already placed by
|
||||
// proximity during world-gen, so the nearest planet is the one whose capture
|
||||
// they should advertise. Distances use the planet SDF (negative inside), which
|
||||
// is exactly the "closest planet" metric we want.
|
||||
// Each lamp is associated with its nearest planet via the planet SDF
|
||||
// (negative inside), so it advertises that planet's capture.
|
||||
const planets = objects.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical);
|
||||
lights
|
||||
.filter((l): l is LampPhysical => l instanceof LampPhysical)
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
import { Options } from './options';
|
||||
|
||||
const isDevelopment = process.env.NODE_ENV !== 'production';
|
||||
|
||||
export const defaultOptions: Options = {
|
||||
port: 3000,
|
||||
name: isDevelopment ? 'Dev server' : 'Doppler server',
|
||||
playerLimit: 16,
|
||||
npcCount: 8,
|
||||
seed: Math.random(),
|
||||
scoreLimit: 2500,
|
||||
};
|
||||
|
|
@ -18,28 +18,18 @@ import {
|
|||
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';
|
||||
import { StepCommand, GeneratePointsCommand, AnnounceCommand } from './commands/commands';
|
||||
|
||||
const gameStateSubscribedRoom = 'gameStateSubscribedRoom';
|
||||
|
||||
interface JoinedSocket {
|
||||
player: Player;
|
||||
onPlayerToServer: (json: string) => void;
|
||||
onDisconnect: () => void;
|
||||
}
|
||||
|
||||
export class GameServer extends CommandReceiver {
|
||||
private objects!: PhysicalContainer;
|
||||
private players!: PlayerContainer;
|
||||
private deltaTimes!: Array<number>;
|
||||
private deltaTimeCalculator!: DeltaTimeCalculator;
|
||||
private lastPhysicsBase: [number, number] = process.hrtime();
|
||||
|
||||
private bluePoints = 0;
|
||||
private redPoints = 0;
|
||||
|
|
@ -47,6 +37,7 @@ export class GameServer extends CommandReceiver {
|
|||
|
||||
private isInEndGame = false;
|
||||
private timeScaling = 1;
|
||||
private statReportMs = Date.now();
|
||||
|
||||
private initialize() {
|
||||
const previousPlayers = this.players;
|
||||
|
|
@ -59,8 +50,7 @@ export class GameServer extends CommandReceiver {
|
|||
this.options.playerLimit,
|
||||
this.options.npcCount,
|
||||
);
|
||||
this.deltaTimeCalculator = new DeltaTimeCalculator();
|
||||
this.deltaTimes = [];
|
||||
this.lastPhysicsBase = process.hrtime();
|
||||
this.bluePoints = 0;
|
||||
this.redPoints = 0;
|
||||
this.matchPointAnnounced = {};
|
||||
|
|
@ -95,7 +85,7 @@ export class GameServer extends CommandReceiver {
|
|||
});
|
||||
}
|
||||
|
||||
private readonly joinedSockets = new Map<Socket, JoinedSocket>();
|
||||
private readonly joinedSockets = new Map<Socket, Player>();
|
||||
|
||||
private handleJoin(socket: Socket, playerInfo: PlayerInformation) {
|
||||
if (this.joinedSockets.has(socket)) {
|
||||
|
|
@ -104,9 +94,6 @@ export class GameServer extends CommandReceiver {
|
|||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -133,16 +120,13 @@ export class GameServer extends CommandReceiver {
|
|||
|
||||
const onPlayerToServer = (json: string) => {
|
||||
try {
|
||||
if (typeof json !== 'string' || json.length > settings.maxInboundMessageBytes) {
|
||||
if (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);
|
||||
|
|
@ -150,18 +134,14 @@ export class GameServer extends CommandReceiver {
|
|||
};
|
||||
|
||||
const onDisconnect = () => {
|
||||
const record = this.joinedSockets.get(socket);
|
||||
const p = 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) {
|
||||
if (!p) {
|
||||
return;
|
||||
}
|
||||
record.player.detachFromSocket();
|
||||
p.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,
|
||||
|
|
@ -176,7 +156,7 @@ export class GameServer extends CommandReceiver {
|
|||
|
||||
socket.on(TransportEvents.PlayerToServer, onPlayerToServer);
|
||||
socket.on('disconnect', onDisconnect);
|
||||
this.joinedSockets.set(socket, { player, onPlayerToServer, onDisconnect });
|
||||
this.joinedSockets.set(socket, player);
|
||||
|
||||
player.reconnectToken = this.players.issueToken();
|
||||
socket.emit(TransportEvents.PlayerJoined, player.reconnectToken);
|
||||
|
|
@ -184,8 +164,6 @@ export class GameServer extends CommandReceiver {
|
|||
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 {
|
||||
|
|
@ -210,13 +188,10 @@ export class GameServer extends CommandReceiver {
|
|||
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();
|
||||
for (const [socket, player] of this.joinedSockets) {
|
||||
socket.removeAllListeners();
|
||||
player.detachFromSocket();
|
||||
}
|
||||
this.joinedSockets.clear();
|
||||
this.inboundBudget.clear();
|
||||
|
|
@ -226,7 +201,10 @@ export class GameServer extends CommandReceiver {
|
|||
public sendServerStateUpdate() {
|
||||
this.io
|
||||
.to(gameStateSubscribedRoom)
|
||||
.emit(TransportEvents.ServerInfoUpdate, [this.players.count, this.gameProgress]);
|
||||
.emit(TransportEvents.ServerInfoUpdate, [
|
||||
this.players.count,
|
||||
(Math.max(this.bluePoints, this.redPoints) / this.options.scoreLimit) * 100,
|
||||
]);
|
||||
}
|
||||
|
||||
public start() {
|
||||
|
|
@ -269,21 +247,32 @@ export class GameServer extends CommandReceiver {
|
|||
const endTitleLength = 6;
|
||||
this.players.endGame(winningTeam);
|
||||
this.players.queueCommandForEachClient(new GameEndCommand());
|
||||
// Rebuild the world for the next round once the end card has been shown.
|
||||
setTimeout(() => this.initialize(), endTitleLength * 1000 * 1.1);
|
||||
}
|
||||
|
||||
private timeSinceLastPointUpdate = 0;
|
||||
private physicsAccumulator = 0;
|
||||
// Frames since the last stats report where physics ran over budget (more
|
||||
// substeps than the cap). Surfaced by handleStats as a saturation signal.
|
||||
private saturatedFrames = 0;
|
||||
|
||||
private handlePhysics() {
|
||||
const delta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds({ setAsBase: true });
|
||||
this.deltaTimes.push(delta);
|
||||
const now = process.hrtime(this.lastPhysicsBase);
|
||||
const delta = now[0] + now[1] / 1e9;
|
||||
this.lastPhysicsBase = process.hrtime();
|
||||
|
||||
this.handleStats();
|
||||
if (Date.now() - this.statReportMs > 30000) {
|
||||
this.statReportMs = Date.now();
|
||||
const mem = `${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`;
|
||||
console.info(`Memory: ${mem}, Players: ${this.players.count}`);
|
||||
|
||||
if (this.droppedInboundMessages > 0) {
|
||||
console.warn(`Rate limited ${this.droppedInboundMessages} inbound msg(s)`);
|
||||
this.droppedInboundMessages = 0;
|
||||
}
|
||||
if (this.saturatedFrames > 0) {
|
||||
console.warn(`Physics saturated on ${this.saturatedFrames} frame(s)`);
|
||||
this.saturatedFrames = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if ((this.timeSinceLastServerStateUpdate += delta) > 4) {
|
||||
this.timeSinceLastServerStateUpdate = 0;
|
||||
|
|
@ -299,18 +288,11 @@ export class GameServer extends CommandReceiver {
|
|||
|
||||
const fixedDelta = settings.targetPhysicsDeltaTimeInSeconds;
|
||||
const maxSubstepsPerFrame = 5;
|
||||
// Cap on retained physics backlog when saturated, so a long stall can't
|
||||
// accumulate an unrecoverable catch-up.
|
||||
const maxBacklogSeconds = 0.25;
|
||||
|
||||
this.physicsAccumulator += delta;
|
||||
let substeps = Math.floor(this.physicsAccumulator / fixedDelta);
|
||||
if (substeps > maxSubstepsPerFrame) {
|
||||
// Saturated: run the cap's worth of substeps but KEEP the remaining
|
||||
// backlog (clamped) instead of zeroing it. Dropping it silently slowed
|
||||
// simulated time for everyone — and diverged client prediction, whose
|
||||
// wall-clock keeps running. Clamping bounds the catch-up so a transient
|
||||
// spike recovers without a death spiral.
|
||||
this.saturatedFrames++;
|
||||
this.physicsAccumulator = Math.min(
|
||||
this.physicsAccumulator - maxSubstepsPerFrame * fixedDelta,
|
||||
|
|
@ -335,7 +317,8 @@ export class GameServer extends CommandReceiver {
|
|||
this.players.stepCommunication(delta);
|
||||
this.objects.resetRemoteCalls();
|
||||
|
||||
const physicsDelta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds();
|
||||
const elapsed = process.hrtime(now);
|
||||
const physicsDelta = elapsed[0] + elapsed[1] / 1e9;
|
||||
|
||||
setTimeout(
|
||||
this.handlePhysics.bind(this),
|
||||
|
|
@ -343,62 +326,13 @@ export class GameServer extends CommandReceiver {
|
|||
);
|
||||
}
|
||||
|
||||
private handleStats() {
|
||||
const framesBetweenDeltaTimeCalculation = 10000;
|
||||
|
||||
if (this.deltaTimes.length > framesBetweenDeltaTimeCalculation) {
|
||||
this.deltaTimes.sort((a, b) => a - b);
|
||||
console.info(
|
||||
`Median physics time: ${(
|
||||
this.deltaTimes[Math.floor(framesBetweenDeltaTimeCalculation / 2)] * 1000
|
||||
).toFixed(2)} ms`,
|
||||
);
|
||||
console.info(
|
||||
'Tail times: ',
|
||||
this.deltaTimes.slice(-20).map((v) => `${(v * 1000).toFixed(2)} ms`),
|
||||
);
|
||||
console.info(
|
||||
`Memory used: ${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`,
|
||||
);
|
||||
|
||||
const rtts = this.players.connectedPlayerRttsMs.filter((r) => r > 0);
|
||||
if (rtts.length > 0) {
|
||||
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})`,
|
||||
);
|
||||
}
|
||||
|
||||
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`,
|
||||
);
|
||||
this.saturatedFrames = 0;
|
||||
}
|
||||
|
||||
this.deltaTimes = [];
|
||||
}
|
||||
}
|
||||
|
||||
private get gameProgress(): number {
|
||||
return (Math.max(this.bluePoints, this.redPoints) / this.options.scoreLimit) * 100;
|
||||
}
|
||||
|
||||
public get serverInfo(): ServerInformation {
|
||||
return {
|
||||
serverName: this.options.name,
|
||||
playerCount: this.players.count,
|
||||
playerLimit: this.options.playerLimit,
|
||||
gameStatePercent: this.gameProgress,
|
||||
gameStatePercent:
|
||||
(Math.max(this.bluePoints, this.redPoints) / this.options.scoreLimit) * 100,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
export class DeltaTimeCalculator {
|
||||
private previousTime: [number, number] = process.hrtime();
|
||||
|
||||
public getNextDeltaTimeInSeconds(
|
||||
{ setAsBase = false }: { setAsBase: boolean } = { setAsBase: false },
|
||||
): number {
|
||||
const [seconds, nanoSeconds] = process.hrtime(this.previousTime);
|
||||
if (setAsBase) {
|
||||
this.previousTime = process.hrtime();
|
||||
}
|
||||
return seconds + nanoSeconds / 1e9;
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import { applyArrayPlugins, Random, serverInformationEndpoint, settings } from '
|
|||
import minimist from 'minimist';
|
||||
import { glMatrix } from 'gl-matrix';
|
||||
import { GameServer } from './game-server';
|
||||
import { defaultOptions } from './default-options';
|
||||
import { defaultOptions } from './options';
|
||||
import parser from 'socket.io-msgpack-parser';
|
||||
|
||||
glMatrix.setMatrixArrayType(Array);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import {
|
|||
settings,
|
||||
MoveActionCommand,
|
||||
serializesTo,
|
||||
last,
|
||||
Circle,
|
||||
CharacterBase,
|
||||
CharacterTeam,
|
||||
|
|
@ -38,12 +37,12 @@ import { ProjectilePhysical } from './projectile-physical';
|
|||
import { forceAtPosition } from '../physics/functions/force-at-position';
|
||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
||||
import { PlanetPhysical } from './planet-physical';
|
||||
import { StepCommand } from '../commands/step';
|
||||
import { ReactToCollisionCommand } from '../commands/react-to-collision';
|
||||
import { GeneratePointsCommand } from '../commands/generate-points';
|
||||
import {
|
||||
StepCommand,
|
||||
ReactToCollisionCommand,
|
||||
GeneratePointsCommand,
|
||||
} from '../commands/commands';
|
||||
|
||||
// The three body circles, as a value — used to diff a tick's movement into the
|
||||
// rate of change the client interpolates on.
|
||||
interface BodyPose {
|
||||
head: Circle;
|
||||
leftFoot: Circle;
|
||||
|
|
@ -69,9 +68,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
|
||||
private killStreak = 0;
|
||||
|
||||
// CharacterMovementState, held directly rather than mirrored through an
|
||||
// adapter: the shared simulation reads and writes these in place, so the
|
||||
// character simply IS the state it is stepped as.
|
||||
// Held in place: the shared simulation reads and writes these directly, so
|
||||
// the character IS the state it is stepped as (client predictor agrees).
|
||||
public direction = 0;
|
||||
public currentPlanet: GroundSurface | undefined;
|
||||
public secondsSinceOnSurface = settings.planetDetachmentSeconds;
|
||||
|
|
@ -127,11 +125,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
container.addObject(this.rightFoot);
|
||||
}
|
||||
|
||||
// The container, presented as the collision/gravity world the shared movement
|
||||
// queries.
|
||||
private readonly movementWorld: CharacterWorld = {
|
||||
// Same set and order forceAtPosition used: planets in the force field, in
|
||||
// container-traversal order (so the f64 gravity sum is unchanged).
|
||||
// Same set and order forceAtPosition uses, so the f64 gravity sum matches.
|
||||
groundsNear: (center, radius) =>
|
||||
this.container
|
||||
.findIntersecting(getBoundingBoxOfCircle(new Circle(center, radius)))
|
||||
|
|
@ -176,15 +171,13 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
this.movementActions.push(c);
|
||||
}
|
||||
|
||||
// The world only ever hands back planets, so narrowing the shared
|
||||
// GroundSurface is safe here.
|
||||
// The world only ever hands back planets, so narrowing GroundSurface is safe.
|
||||
public get groundPlanet(): PlanetPhysical | undefined {
|
||||
return this.currentPlanet as PlanetPhysical | undefined;
|
||||
}
|
||||
|
||||
// The continuous movement state streamed to the owning client, so its
|
||||
// predictor resumes this simulation instead of guessing at the parts of it
|
||||
// the pose does not show. See CharacterMovementSnapshot.
|
||||
// Continuous movement state streamed to the owning client so its predictor
|
||||
// resumes this simulation instead of guessing. See CharacterMovementSnapshot.
|
||||
public get movementSnapshot(): CharacterMovementSnapshot {
|
||||
return new CharacterMovementSnapshot(
|
||||
this.direction,
|
||||
|
|
@ -242,7 +235,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
this.remoteCall('setHealth', this.health);
|
||||
|
||||
if (this.health <= 0 && this.isAlive) {
|
||||
// Throw the corpse along the killing shot, harder for charged hits.
|
||||
vec2.scaleAndAdd(
|
||||
this.bodyVelocity,
|
||||
this.bodyVelocity,
|
||||
|
|
@ -303,9 +295,6 @@ 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) {
|
||||
|
|
@ -374,7 +363,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
|
||||
vec2.scale(direction, direction, 1 / this.movementActions.length);
|
||||
|
||||
this.lastMovementAction = last(this.movementActions)!;
|
||||
const actions = this.movementActions;
|
||||
this.lastMovementAction = actions[actions.length - 1]!;
|
||||
this.movementActions = [];
|
||||
}
|
||||
|
||||
|
|
@ -401,9 +391,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
]);
|
||||
}
|
||||
|
||||
// Rate of change of one body part over the tick, which the client's
|
||||
// interpolator coasts on when a snapshot is late. `previous` is consumed as
|
||||
// scratch so this allocates nothing beyond the Circle it returns.
|
||||
// `previous` is consumed as scratch so this allocates nothing beyond the
|
||||
// Circle it returns.
|
||||
private static rateOfChange(previous: Circle, current: Circle, deltaTime: number) {
|
||||
return new Circle(
|
||||
vec2.scale(
|
||||
|
|
@ -472,15 +461,10 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
|
||||
this.regenerateHealth(deltaTimeInSeconds);
|
||||
|
||||
// The planet tallies who is standing on it and resolves capture itself, so
|
||||
// a contested rock can freeze instead of two squads silently cancelling.
|
||||
this.groundPlanet?.registerPresence(this);
|
||||
|
||||
// The whole walking model — gravity gather, movement force, on/off-planet
|
||||
// branch, posture springs, body-momentum, and stepping the three parts —
|
||||
// is the shared simulation the client predicts with, so the two can never
|
||||
// drift. Server-only concerns (scoring, health, shooting, spawn/death,
|
||||
// ownership) stay here around it.
|
||||
// stepCharacterMovement is the shared simulation the client predicts with,
|
||||
// so the two can never drift.
|
||||
const direction = this.averageAndResetMovementActions();
|
||||
stepCharacterMovement(this, this.movementWorld, direction, deltaTimeInSeconds);
|
||||
|
||||
|
|
@ -502,10 +486,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
grounded = true;
|
||||
}
|
||||
}
|
||||
// Brake with the exact shared model the living body uses: stiff on contact
|
||||
// so the corpse skids to rest, gentle in the air, plus the constant stop and
|
||||
// the speed cap — so a flung corpse comes to a definite stop instead of
|
||||
// sliding forever.
|
||||
// Brake with the shared model the living body uses, so a flung corpse
|
||||
// comes to a definite stop instead of sliding forever.
|
||||
decayMomentum(this.bodyVelocity, grounded, deltaTime);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { PhysicalContainer } from '../physics/containers/physical-container';
|
|||
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
|
||||
import { Physical } from '../physics/physicals/physical';
|
||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
||||
import { ReactToCollisionCommand } from '../commands/react-to-collision';
|
||||
import { ReactToCollisionCommand } from '../commands/commands';
|
||||
|
||||
@serializesTo(Circle)
|
||||
export class CirclePhysical extends CommandReceiver implements Circle, DynamicPhysical {
|
||||
|
|
@ -35,8 +35,8 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh
|
|||
private _radius: number,
|
||||
public owner: GameObject,
|
||||
private readonly container: PhysicalContainer,
|
||||
// Public + readonly so a CirclePhysical structurally satisfies the shared
|
||||
// PhysicsBody interface the movement simulation operates on.
|
||||
// Public + readonly so CirclePhysical satisfies the shared PhysicsBody
|
||||
// interface the movement simulation operates on.
|
||||
public readonly restitution = 0,
|
||||
) {
|
||||
super();
|
||||
|
|
@ -89,14 +89,10 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh
|
|||
applyForce(this, force, timeInSeconds);
|
||||
}
|
||||
|
||||
// Position-resolution for one tick. Delegates to the shared
|
||||
// resolveCircleMovement so the server integrates a body with the exact same
|
||||
// geometry the client predictor runs (shared/physics) — no parallel copy to
|
||||
// keep in sync. The onHit callback dispatches the collision reactions at the
|
||||
// same points the old inline move-circle did (both the initial march and the
|
||||
// post-bounce slide). `possibleIntersectors`, when supplied, lets a caller
|
||||
// that already broadphased (e.g. a projectile's gravity query) avoid a second
|
||||
// container query; otherwise it is self-gathered from the swept bounding box.
|
||||
// Delegates to the shared resolveCircleMovement so the server integrates with
|
||||
// the exact same geometry the client predictor runs — no parallel copy to
|
||||
// keep in sync. `possibleIntersectors` lets a caller that already broadphased
|
||||
// avoid a second container query; otherwise it is self-gathered.
|
||||
public stepManually(
|
||||
deltaTimeInSeconds: number,
|
||||
possibleIntersectors?: Array<Physical>,
|
||||
|
|
@ -119,20 +115,17 @@ 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 };
|
||||
}
|
||||
|
||||
// Query the container with the bounding box grown by this tick's travel, so a
|
||||
// fast-moving body still sees what it is about to sweep into.
|
||||
private sweptBroadphase(deltaTimeInSeconds: number): Array<Physical> {
|
||||
const sweep = vec2.length(
|
||||
vec2.scale(vec2.create(), this.velocity, deltaTimeInSeconds),
|
||||
);
|
||||
// Grow the query, not the object: inflating and deflating this.radius ran
|
||||
// the setter (and so re-registered the body's extent) twice mid-tick.
|
||||
// Grow the query box, not this.radius: mutating the radius would re-register
|
||||
// the body's extent mid-tick via the setter.
|
||||
return this.container.findIntersecting(
|
||||
getBoundingBoxOfCircle(new Circle(this.center, this.radius + sweep)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -17,9 +17,11 @@ import {
|
|||
CommandExecutors,
|
||||
CommandReceiver,
|
||||
} from 'shared';
|
||||
import { GeneratePointsCommand } from '../commands/generate-points';
|
||||
import { AnnounceCommand } from '../commands/announce';
|
||||
import { StepCommand } from '../commands/step';
|
||||
import {
|
||||
GeneratePointsCommand,
|
||||
AnnounceCommand,
|
||||
StepCommand,
|
||||
} from '../commands/commands';
|
||||
|
||||
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
|
||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
||||
|
|
@ -31,16 +33,15 @@ import type { CharacterPhysical } from './character-physical';
|
|||
export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
||||
public readonly canCollide = true;
|
||||
public readonly canMove = false;
|
||||
// Marks this as standable ground for the shared movement simulation (a body
|
||||
// landing on it latches it as currentPlanet). See shared GroundSurface.
|
||||
// Marks this as standable ground for the shared movement simulation.
|
||||
public readonly isGround = true;
|
||||
|
||||
public readonly sizePointMultiplier: number;
|
||||
|
||||
// Planets slowly spin. The angle is authoritative here and streamed to the
|
||||
// client (see getPropertyUpdates), so the rendered outline and this collision
|
||||
// polygon turn as one rigid body. cos/sin are memoised per angle because
|
||||
// distance() is called many times per tick by the raymarcher and SDF sampling.
|
||||
// Authoritative spin angle, streamed to the client so the rendered outline
|
||||
// and this collision polygon turn as one. cos/sin are memoised: distance()
|
||||
// is called many times per tick by the raymarcher and the angle rarely
|
||||
// changes between those calls.
|
||||
private rotation = 0;
|
||||
private readonly rotationSpeed: number;
|
||||
private cachedRotation = Number.NaN;
|
||||
|
|
@ -55,7 +56,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
|||
|
||||
// Characters standing on the planet this tick. Filled by registerPresence as
|
||||
// each grounded character steps, drained when the planet resolves capture in
|
||||
// its own step(). Drives the head-count tug-of-war.
|
||||
// its own step().
|
||||
private presentCharacters: Array<CharacterPhysical> = [];
|
||||
private isContested = false;
|
||||
|
||||
|
|
@ -81,17 +82,13 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
|||
(0.05 + Random.getRandom() * 0.07) * (Random.getRandom() < 0.5 ? -1 : 1);
|
||||
}
|
||||
|
||||
// A grounded character announces itself each tick so the planet can resolve
|
||||
// contested capture from the net head-count.
|
||||
public registerPresence(character: CharacterPhysical) {
|
||||
this.presentCharacters.push(character);
|
||||
}
|
||||
|
||||
public distance(target: vec2): number {
|
||||
// The one shared planet outline — the same function the client predictor
|
||||
// collides against, which is what lets prediction reconcile. Only the
|
||||
// cos/sin memo lives here, because distance() is called many times per tick
|
||||
// by the raymarcher and the angle rarely changes between those calls.
|
||||
// The shared planet outline — the same function the client predictor
|
||||
// collides against, which is what lets prediction reconcile.
|
||||
this.syncRotationTrigonometry();
|
||||
return planetDistance(
|
||||
target,
|
||||
|
|
@ -110,8 +107,8 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
|||
}
|
||||
}
|
||||
|
||||
// Signed angular velocity in rad/s, exposed so a character standing on the
|
||||
// planet can ride its spin (see carryWithRotatingPlanet in shared).
|
||||
// Signed angular velocity in rad/s, exposed so a character can ride the
|
||||
// planet's spin (see carryWithRotatingPlanet in shared).
|
||||
public get angularVelocity(): number {
|
||||
return this.rotationSpeed;
|
||||
}
|
||||
|
|
@ -145,9 +142,6 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
|||
this.presentCharacters = [];
|
||||
}
|
||||
|
||||
// One capture step per tick driven by the net team head-count, so grouping up
|
||||
// pays off and an equal standoff freezes the planet (contested) instead of
|
||||
// both sides silently cancelling with no feedback.
|
||||
private resolveCapture(deltaTime: number) {
|
||||
let blue = 0;
|
||||
let red = 0;
|
||||
|
|
@ -168,14 +162,11 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
|||
deltaTime * lead,
|
||||
);
|
||||
} else if (!occupied) {
|
||||
// Empty planets drift back to neutral; the keystone drifts much slower so
|
||||
// it lingers as a live flashpoint.
|
||||
this.takeControl(
|
||||
CharacterTeam.neutral,
|
||||
this.isKeystone ? deltaTime / settings.keystoneLoseControlScale : deltaTime,
|
||||
);
|
||||
}
|
||||
// occupied tie -> frozen tug-of-war: no ownership change, ring pulses.
|
||||
|
||||
const contested = occupied && net === 0;
|
||||
if (contested !== this.isContested) {
|
||||
|
|
@ -184,7 +175,6 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
|||
}
|
||||
}
|
||||
|
||||
// hysteresis
|
||||
private get flipTeam(): CharacterTeam {
|
||||
const control = this.ownership - 0.5;
|
||||
const enter = settings.planetControlThreshold + settings.planetFlipHysteresis;
|
||||
|
|
@ -292,11 +282,6 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
|||
return planetGravity(this.center, this.radius, position);
|
||||
}
|
||||
|
||||
// forceAtPosition's name for the same thing.
|
||||
public getForce(position: vec2): vec2 {
|
||||
return this.gravityAt(position);
|
||||
}
|
||||
|
||||
public get gameObject(): this {
|
||||
return this;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,8 +18,7 @@ import { PhysicalContainer } from '../physics/containers/physical-container';
|
|||
import { CharacterPhysical } from './character-physical';
|
||||
import { forceAtPosition } from '../physics/functions/force-at-position';
|
||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
||||
import { StepCommand } from '../commands/step';
|
||||
import { ReactToCollisionCommand } from '../commands/react-to-collision';
|
||||
import { StepCommand, ReactToCollisionCommand } from '../commands/commands';
|
||||
|
||||
@serializesTo(ProjectileBase)
|
||||
export class ProjectilePhysical extends ProjectileBase implements DynamicPhysical {
|
||||
|
|
@ -44,8 +43,8 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
|
|||
private velocity: vec2,
|
||||
public readonly originator: CharacterPhysical,
|
||||
readonly container: PhysicalContainer,
|
||||
// Normalised charge (0..1) of the shot that fired this, used by the victim
|
||||
// to scale hit/kill feedback and the death fling.
|
||||
// Normalised charge (0..1) of the shot that fired this; the victim scales
|
||||
// hit/kill feedback and the death fling by it.
|
||||
public readonly charge: number = 0,
|
||||
) {
|
||||
super(id(), center, radius, team, strength);
|
||||
|
|
@ -89,11 +88,9 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
|
|||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Lag compensation: runs the projectile forward by the time its command spent
|
||||
* travelling to the server. Capped by settings.maxProjectileCatchUpSeconds so
|
||||
* a forged timestamp cannot spawn a shot arbitrarily far downrange.
|
||||
*/
|
||||
public fastForward(seconds: number) {
|
||||
const step = settings.targetPhysicsDeltaTimeInSeconds;
|
||||
|
|
@ -106,9 +103,8 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
|
|||
}
|
||||
}
|
||||
|
||||
// The circle keeps its own box in sync as it moves, so this is a live view of
|
||||
// it, not a snapshot — caching it saved nothing and mistyped a mutable box as
|
||||
// immutable.
|
||||
// Live view of the circle's box, not a snapshot — the circle keeps it in sync
|
||||
// as it moves.
|
||||
public get boundingBox(): BoundingBoxBase {
|
||||
return this.object.boundingBox;
|
||||
}
|
||||
|
|
@ -155,9 +151,6 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
|
|||
return;
|
||||
}
|
||||
|
||||
// Curveball: the same planetary gravity that pulls on a free-falling
|
||||
// character bends the shot, so slower (charged) shots arc and can be lobbed
|
||||
// over a planet's horizon. Scale is tiny — near-surface gravity is huge.
|
||||
// This single broadphase is reused for the step below: the gravity radius
|
||||
// (maxGravityDistance) dwarfs one tick's travel, so the set is a superset of
|
||||
// the swept-collision box and the step needn't query the container again.
|
||||
|
|
|
|||
|
|
@ -6,3 +6,14 @@ export interface Options {
|
|||
npcCount: number;
|
||||
seed: number;
|
||||
}
|
||||
|
||||
const isDevelopment = process.env.NODE_ENV !== 'production';
|
||||
|
||||
export const defaultOptions: Options = {
|
||||
port: 3000,
|
||||
name: isDevelopment ? 'Dev server' : 'Doppler server',
|
||||
playerLimit: 16,
|
||||
npcCount: 8,
|
||||
seed: Math.random(),
|
||||
scoreLimit: 2500,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// axis-aligned
|
||||
export abstract class BoundingBoxBase {
|
||||
constructor(
|
||||
protected _xMin: number = 0,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,6 @@ export const forceAtPosition = (position: vec2, objects: Array<Physical>) =>
|
|||
.filter((o) => o instanceof PlanetPhysical)
|
||||
.reduce(
|
||||
(sum: vec2, o: Physical) =>
|
||||
vec2.add(sum, sum, (o as PlanetPhysical).getForce(position)),
|
||||
vec2.add(sum, sum, (o as PlanetPhysical).gravityAt(position)),
|
||||
vec2.create(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
import { Circle, evaluateSdf } from 'shared';
|
||||
import { PhysicalBase } from '../physicals/physical-base';
|
||||
|
||||
export const isCircleIntersecting = (
|
||||
circle: Circle,
|
||||
intersectors: Array<PhysicalBase>,
|
||||
): boolean => evaluateSdf(circle.center, intersectors) < circle.radius;
|
||||
|
|
@ -7,7 +7,6 @@ import {
|
|||
MoveActionCommand,
|
||||
CharacterTeam,
|
||||
Id,
|
||||
rotate90Deg,
|
||||
} from 'shared';
|
||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||
import { PlayerContainer } from './player-container';
|
||||
|
|
@ -62,8 +61,6 @@ const npcTuning = {
|
|||
spreadPerDistance: 0.08,
|
||||
spreadAggressionFalloff: 1.3,
|
||||
|
||||
// Per-second chance to hop while grounded and on the move, so bots use the
|
||||
// leap verb too instead of being grounded targets.
|
||||
leapChancePerSecond: 0.35,
|
||||
};
|
||||
|
||||
|
|
@ -259,7 +256,7 @@ export class NPC extends PlayerBase {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const perpendicular = rotate90Deg(threat.direction);
|
||||
const perpendicular = vec2.fromValues(-threat.direction[1], threat.direction[0]);
|
||||
const toMe = vec2.subtract(vec2.create(), this.center, threat.center);
|
||||
if (vec2.dot(perpendicular, toMe) < 0) {
|
||||
vec2.negate(perpendicular, perpendicular);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
} from 'shared';
|
||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
||||
import { isCircleIntersecting } from '../physics/functions/is-circle-intersecting';
|
||||
import { evaluateSdf } from 'shared';
|
||||
import { CharacterPhysical } from '../objects/character-physical';
|
||||
import { PlanetPhysical } from '../objects/planet-physical';
|
||||
import { PlayerContainer } from './player-container';
|
||||
|
|
@ -37,11 +37,9 @@ export abstract class PlayerBase extends CommandReceiver {
|
|||
|
||||
protected createCharacter() {
|
||||
this.character = new CharacterPhysical(
|
||||
// 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.
|
||||
// Coerce, don't just truncate: input is untrusted (from deserialize), and
|
||||
// arrays have `.slice()` too — a non-string name would throw inside the
|
||||
// reviver and kill the recipient's whole message batch.
|
||||
sanitizeName(this.playerInfo.name, maximumNameLength),
|
||||
this.sumKills,
|
||||
this.sumDeaths,
|
||||
|
|
@ -56,12 +54,8 @@ export abstract class PlayerBase extends CommandReceiver {
|
|||
public abstract step(deltaTimeInSeconds: number): void;
|
||||
|
||||
/**
|
||||
* The death/respawn lifecycle both a connected player and an NPC go through:
|
||||
* bank the score off a body that just died, hold the corpse for the respawn
|
||||
* timeout, then build a new one. Returns whether there is a living character
|
||||
* to act with this tick — returned as the character itself, so a subclass can
|
||||
* `const character = this.stepLifecycle(dt); if (!character) return;` and go
|
||||
* on to use it without re-narrowing.
|
||||
* Shared death/respawn cycle. Returns the living character to act with this
|
||||
* tick (or undefined), so a subclass can use it directly without re-narrowing.
|
||||
*/
|
||||
protected stepLifecycle(deltaTimeInSeconds: number): CharacterPhysical | undefined {
|
||||
if (this.character) {
|
||||
|
|
@ -87,8 +81,7 @@ export abstract class PlayerBase extends CommandReceiver {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
// Hooks for what a subclass wants to do with the body it just lost, and just
|
||||
// before a new one is built. Both are no-ops by default.
|
||||
// Override hooks (no-ops by default).
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
protected onCharacterDied(character: CharacterPhysical) {}
|
||||
protected onBeforeRespawn() {}
|
||||
|
|
@ -120,8 +113,6 @@ 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;
|
||||
|
|
@ -141,7 +132,10 @@ export abstract class PlayerBase extends CommandReceiver {
|
|||
const playerBoundingBox = getBoundingBoxOfCircle(playerBoundingCircle);
|
||||
const possibleIntersectors =
|
||||
this.objectContainer.findIntersecting(playerBoundingBox);
|
||||
if (!isCircleIntersecting(playerBoundingCircle, possibleIntersectors)) {
|
||||
if (
|
||||
evaluateSdf(playerBoundingCircle.center, possibleIntersectors) >=
|
||||
playerBoundingCircle.radius
|
||||
) {
|
||||
return playerPosition;
|
||||
}
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue