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 =
|
const planet =
|
||||||
objects.length === 0
|
objects.length === 0
|
||||||
? // The first, central giant is the keystone "Heart": a named,
|
? new PlanetPhysical(
|
||||||
// always-contested focal objective the whole match orbits.
|
|
||||||
new PlanetPhysical(
|
|
||||||
PlanetBase.createPlanetVertices(
|
PlanetBase.createPlanetVertices(
|
||||||
position,
|
position,
|
||||||
Random.getRandomInRange(1600, 2400),
|
Random.getRandomInRange(1600, 2400),
|
||||||
|
|
@ -72,11 +70,8 @@ export const createWorld = (objectContainer: PhysicalContainer) => {
|
||||||
console.info(`Generated ${objects.length} planets`);
|
console.info(`Generated ${objects.length} planets`);
|
||||||
console.info(`Generated ${lights.length} light`);
|
console.info(`Generated ${lights.length} light`);
|
||||||
|
|
||||||
// Associate each lamp with its NEAREST planet, so a planet can repaint "its"
|
// Each lamp is associated with its nearest planet via the planet SDF
|
||||||
// lamps to the owning team's colour when it flips. Lamps are already placed by
|
// (negative inside), so it advertises that planet's capture.
|
||||||
// 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.
|
|
||||||
const planets = objects.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical);
|
const planets = objects.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical);
|
||||||
lights
|
lights
|
||||||
.filter((l): l is LampPhysical => l instanceof LampPhysical)
|
.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,
|
beginPropertyUpdateGeneration,
|
||||||
} from 'shared';
|
} from 'shared';
|
||||||
import { createWorld } from './create-world';
|
import { createWorld } from './create-world';
|
||||||
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 { ServerFullError } from './players/server-full-error';
|
||||||
import { Player } from './players/player';
|
import { Player } from './players/player';
|
||||||
import { StepCommand } from './commands/step';
|
import { StepCommand, GeneratePointsCommand, AnnounceCommand } from './commands/commands';
|
||||||
import { GeneratePointsCommand } from './commands/generate-points';
|
|
||||||
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;
|
||||||
private deltaTimes!: Array<number>;
|
private lastPhysicsBase: [number, number] = process.hrtime();
|
||||||
private deltaTimeCalculator!: DeltaTimeCalculator;
|
|
||||||
|
|
||||||
private bluePoints = 0;
|
private bluePoints = 0;
|
||||||
private redPoints = 0;
|
private redPoints = 0;
|
||||||
|
|
@ -47,6 +37,7 @@ export class GameServer extends CommandReceiver {
|
||||||
|
|
||||||
private isInEndGame = false;
|
private isInEndGame = false;
|
||||||
private timeScaling = 1;
|
private timeScaling = 1;
|
||||||
|
private statReportMs = Date.now();
|
||||||
|
|
||||||
private initialize() {
|
private initialize() {
|
||||||
const previousPlayers = this.players;
|
const previousPlayers = this.players;
|
||||||
|
|
@ -59,8 +50,7 @@ export class GameServer extends CommandReceiver {
|
||||||
this.options.playerLimit,
|
this.options.playerLimit,
|
||||||
this.options.npcCount,
|
this.options.npcCount,
|
||||||
);
|
);
|
||||||
this.deltaTimeCalculator = new DeltaTimeCalculator();
|
this.lastPhysicsBase = process.hrtime();
|
||||||
this.deltaTimes = [];
|
|
||||||
this.bluePoints = 0;
|
this.bluePoints = 0;
|
||||||
this.redPoints = 0;
|
this.redPoints = 0;
|
||||||
this.matchPointAnnounced = {};
|
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) {
|
private handleJoin(socket: Socket, playerInfo: PlayerInformation) {
|
||||||
if (this.joinedSockets.has(socket)) {
|
if (this.joinedSockets.has(socket)) {
|
||||||
|
|
@ -104,9 +94,6 @@ export class GameServer extends CommandReceiver {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isInEndGame) {
|
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);
|
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.RoundEnding);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -133,16 +120,13 @@ export class GameServer extends CommandReceiver {
|
||||||
|
|
||||||
const onPlayerToServer = (json: string) => {
|
const onPlayerToServer = (json: string) => {
|
||||||
try {
|
try {
|
||||||
if (typeof json !== 'string' || json.length > settings.maxInboundMessageBytes) {
|
if (json.length > settings.maxInboundMessageBytes) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.allowInboundMessage(socket)) {
|
if (!this.allowInboundMessage(socket)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const commands: Array<Command> = deserialize(json);
|
const commands: Array<Command> = deserialize(json);
|
||||||
if (!Array.isArray(commands)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
commands.forEach((c) => player.handleCommand(c));
|
commands.forEach((c) => player.handleCommand(c));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error while processing command', e);
|
console.error('Error while processing command', e);
|
||||||
|
|
@ -150,18 +134,14 @@ export class GameServer extends CommandReceiver {
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDisconnect = () => {
|
const onDisconnect = () => {
|
||||||
const record = this.joinedSockets.get(socket);
|
const p = this.joinedSockets.get(socket);
|
||||||
this.joinedSockets.delete(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);
|
this.inboundBudget.delete(socket);
|
||||||
if (!record) {
|
if (!p) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
record.player.detachFromSocket();
|
p.detachFromSocket();
|
||||||
const { kills, deaths } = player.scoreSnapshot;
|
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(
|
this.players.reserveScore(
|
||||||
player.reconnectToken,
|
player.reconnectToken,
|
||||||
player.team,
|
player.team,
|
||||||
|
|
@ -176,7 +156,7 @@ export class GameServer extends CommandReceiver {
|
||||||
|
|
||||||
socket.on(TransportEvents.PlayerToServer, onPlayerToServer);
|
socket.on(TransportEvents.PlayerToServer, onPlayerToServer);
|
||||||
socket.on('disconnect', onDisconnect);
|
socket.on('disconnect', onDisconnect);
|
||||||
this.joinedSockets.set(socket, { player, onPlayerToServer, onDisconnect });
|
this.joinedSockets.set(socket, player);
|
||||||
|
|
||||||
player.reconnectToken = this.players.issueToken();
|
player.reconnectToken = this.players.issueToken();
|
||||||
socket.emit(TransportEvents.PlayerJoined, player.reconnectToken);
|
socket.emit(TransportEvents.PlayerJoined, player.reconnectToken);
|
||||||
|
|
@ -184,8 +164,6 @@ export class GameServer extends CommandReceiver {
|
||||||
this.sendServerStateUpdate();
|
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 readonly inboundBudget = new Map<Socket, { tokens: number; lastMs: number }>();
|
||||||
private droppedInboundMessages = 0;
|
private droppedInboundMessages = 0;
|
||||||
private allowInboundMessage(socket: Socket): boolean {
|
private allowInboundMessage(socket: Socket): boolean {
|
||||||
|
|
@ -210,13 +188,10 @@ export class GameServer extends CommandReceiver {
|
||||||
return true;
|
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() {
|
private releaseJoinedSockets() {
|
||||||
for (const [socket, record] of this.joinedSockets) {
|
for (const [socket, player] of this.joinedSockets) {
|
||||||
socket.off(TransportEvents.PlayerToServer, record.onPlayerToServer);
|
socket.removeAllListeners();
|
||||||
socket.off('disconnect', record.onDisconnect);
|
player.detachFromSocket();
|
||||||
record.player.detachFromSocket();
|
|
||||||
}
|
}
|
||||||
this.joinedSockets.clear();
|
this.joinedSockets.clear();
|
||||||
this.inboundBudget.clear();
|
this.inboundBudget.clear();
|
||||||
|
|
@ -226,7 +201,10 @@ export class GameServer extends CommandReceiver {
|
||||||
public sendServerStateUpdate() {
|
public sendServerStateUpdate() {
|
||||||
this.io
|
this.io
|
||||||
.to(gameStateSubscribedRoom)
|
.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() {
|
public start() {
|
||||||
|
|
@ -269,21 +247,32 @@ export class GameServer extends CommandReceiver {
|
||||||
const endTitleLength = 6;
|
const endTitleLength = 6;
|
||||||
this.players.endGame(winningTeam);
|
this.players.endGame(winningTeam);
|
||||||
this.players.queueCommandForEachClient(new GameEndCommand());
|
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);
|
setTimeout(() => this.initialize(), endTitleLength * 1000 * 1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
private timeSinceLastPointUpdate = 0;
|
private timeSinceLastPointUpdate = 0;
|
||||||
private physicsAccumulator = 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 saturatedFrames = 0;
|
||||||
|
|
||||||
private handlePhysics() {
|
private handlePhysics() {
|
||||||
const delta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds({ setAsBase: true });
|
const now = process.hrtime(this.lastPhysicsBase);
|
||||||
this.deltaTimes.push(delta);
|
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) {
|
if ((this.timeSinceLastServerStateUpdate += delta) > 4) {
|
||||||
this.timeSinceLastServerStateUpdate = 0;
|
this.timeSinceLastServerStateUpdate = 0;
|
||||||
|
|
@ -299,18 +288,11 @@ export class GameServer extends CommandReceiver {
|
||||||
|
|
||||||
const fixedDelta = settings.targetPhysicsDeltaTimeInSeconds;
|
const fixedDelta = settings.targetPhysicsDeltaTimeInSeconds;
|
||||||
const maxSubstepsPerFrame = 5;
|
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;
|
const maxBacklogSeconds = 0.25;
|
||||||
|
|
||||||
this.physicsAccumulator += delta;
|
this.physicsAccumulator += delta;
|
||||||
let substeps = Math.floor(this.physicsAccumulator / fixedDelta);
|
let substeps = Math.floor(this.physicsAccumulator / fixedDelta);
|
||||||
if (substeps > maxSubstepsPerFrame) {
|
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.saturatedFrames++;
|
||||||
this.physicsAccumulator = Math.min(
|
this.physicsAccumulator = Math.min(
|
||||||
this.physicsAccumulator - maxSubstepsPerFrame * fixedDelta,
|
this.physicsAccumulator - maxSubstepsPerFrame * fixedDelta,
|
||||||
|
|
@ -335,7 +317,8 @@ export class GameServer extends CommandReceiver {
|
||||||
this.players.stepCommunication(delta);
|
this.players.stepCommunication(delta);
|
||||||
this.objects.resetRemoteCalls();
|
this.objects.resetRemoteCalls();
|
||||||
|
|
||||||
const physicsDelta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds();
|
const elapsed = process.hrtime(now);
|
||||||
|
const physicsDelta = elapsed[0] + elapsed[1] / 1e9;
|
||||||
|
|
||||||
setTimeout(
|
setTimeout(
|
||||||
this.handlePhysics.bind(this),
|
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 {
|
public get serverInfo(): ServerInformation {
|
||||||
return {
|
return {
|
||||||
serverName: this.options.name,
|
serverName: this.options.name,
|
||||||
playerCount: this.players.count,
|
playerCount: this.players.count,
|
||||||
playerLimit: this.options.playerLimit,
|
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 minimist from 'minimist';
|
||||||
import { glMatrix } from 'gl-matrix';
|
import { glMatrix } from 'gl-matrix';
|
||||||
import { GameServer } from './game-server';
|
import { GameServer } from './game-server';
|
||||||
import { defaultOptions } from './default-options';
|
import { defaultOptions } from './options';
|
||||||
import parser from 'socket.io-msgpack-parser';
|
import parser from 'socket.io-msgpack-parser';
|
||||||
|
|
||||||
glMatrix.setMatrixArrayType(Array);
|
glMatrix.setMatrixArrayType(Array);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import {
|
||||||
settings,
|
settings,
|
||||||
MoveActionCommand,
|
MoveActionCommand,
|
||||||
serializesTo,
|
serializesTo,
|
||||||
last,
|
|
||||||
Circle,
|
Circle,
|
||||||
CharacterBase,
|
CharacterBase,
|
||||||
CharacterTeam,
|
CharacterTeam,
|
||||||
|
|
@ -38,12 +37,12 @@ import { ProjectilePhysical } from './projectile-physical';
|
||||||
import { forceAtPosition } from '../physics/functions/force-at-position';
|
import { forceAtPosition } from '../physics/functions/force-at-position';
|
||||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
||||||
import { PlanetPhysical } from './planet-physical';
|
import { PlanetPhysical } from './planet-physical';
|
||||||
import { StepCommand } from '../commands/step';
|
import {
|
||||||
import { ReactToCollisionCommand } from '../commands/react-to-collision';
|
StepCommand,
|
||||||
import { GeneratePointsCommand } from '../commands/generate-points';
|
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 {
|
interface BodyPose {
|
||||||
head: Circle;
|
head: Circle;
|
||||||
leftFoot: Circle;
|
leftFoot: Circle;
|
||||||
|
|
@ -69,9 +68,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
|
|
||||||
private killStreak = 0;
|
private killStreak = 0;
|
||||||
|
|
||||||
// CharacterMovementState, held directly rather than mirrored through an
|
// Held in place: the shared simulation reads and writes these directly, so
|
||||||
// adapter: the shared simulation reads and writes these in place, so the
|
// the character IS the state it is stepped as (client predictor agrees).
|
||||||
// character simply IS the state it is stepped as.
|
|
||||||
public direction = 0;
|
public direction = 0;
|
||||||
public currentPlanet: GroundSurface | undefined;
|
public currentPlanet: GroundSurface | undefined;
|
||||||
public secondsSinceOnSurface = settings.planetDetachmentSeconds;
|
public secondsSinceOnSurface = settings.planetDetachmentSeconds;
|
||||||
|
|
@ -127,11 +125,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
container.addObject(this.rightFoot);
|
container.addObject(this.rightFoot);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The container, presented as the collision/gravity world the shared movement
|
|
||||||
// queries.
|
|
||||||
private readonly movementWorld: CharacterWorld = {
|
private readonly movementWorld: CharacterWorld = {
|
||||||
// Same set and order forceAtPosition used: planets in the force field, in
|
// Same set and order forceAtPosition uses, so the f64 gravity sum matches.
|
||||||
// container-traversal order (so the f64 gravity sum is unchanged).
|
|
||||||
groundsNear: (center, radius) =>
|
groundsNear: (center, radius) =>
|
||||||
this.container
|
this.container
|
||||||
.findIntersecting(getBoundingBoxOfCircle(new Circle(center, radius)))
|
.findIntersecting(getBoundingBoxOfCircle(new Circle(center, radius)))
|
||||||
|
|
@ -176,15 +171,13 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
this.movementActions.push(c);
|
this.movementActions.push(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The world only ever hands back planets, so narrowing the shared
|
// The world only ever hands back planets, so narrowing GroundSurface is safe.
|
||||||
// GroundSurface is safe here.
|
|
||||||
public get groundPlanet(): PlanetPhysical | undefined {
|
public get groundPlanet(): PlanetPhysical | undefined {
|
||||||
return this.currentPlanet as PlanetPhysical | undefined;
|
return this.currentPlanet as PlanetPhysical | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The continuous movement state streamed to the owning client, so its
|
// Continuous movement state streamed to the owning client so its predictor
|
||||||
// predictor resumes this simulation instead of guessing at the parts of it
|
// resumes this simulation instead of guessing. See CharacterMovementSnapshot.
|
||||||
// the pose does not show. See CharacterMovementSnapshot.
|
|
||||||
public get movementSnapshot(): CharacterMovementSnapshot {
|
public get movementSnapshot(): CharacterMovementSnapshot {
|
||||||
return new CharacterMovementSnapshot(
|
return new CharacterMovementSnapshot(
|
||||||
this.direction,
|
this.direction,
|
||||||
|
|
@ -242,7 +235,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
this.remoteCall('setHealth', this.health);
|
this.remoteCall('setHealth', this.health);
|
||||||
|
|
||||||
if (this.health <= 0 && this.isAlive) {
|
if (this.health <= 0 && this.isAlive) {
|
||||||
// Throw the corpse along the killing shot, harder for charged hits.
|
|
||||||
vec2.scaleAndAdd(
|
vec2.scaleAndAdd(
|
||||||
this.bodyVelocity,
|
this.bodyVelocity,
|
||||||
this.bodyVelocity,
|
this.bodyVelocity,
|
||||||
|
|
@ -303,9 +295,6 @@ 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);
|
projectile.fastForward(catchUpSeconds);
|
||||||
|
|
||||||
if (c > 0) {
|
if (c > 0) {
|
||||||
|
|
@ -374,7 +363,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
|
|
||||||
vec2.scale(direction, direction, 1 / this.movementActions.length);
|
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 = [];
|
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
|
// `previous` is consumed as scratch so this allocates nothing beyond the
|
||||||
// interpolator coasts on when a snapshot is late. `previous` is consumed as
|
// Circle it returns.
|
||||||
// scratch so this allocates nothing beyond the Circle it returns.
|
|
||||||
private static rateOfChange(previous: Circle, current: Circle, deltaTime: number) {
|
private static rateOfChange(previous: Circle, current: Circle, deltaTime: number) {
|
||||||
return new Circle(
|
return new Circle(
|
||||||
vec2.scale(
|
vec2.scale(
|
||||||
|
|
@ -472,15 +461,10 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
|
|
||||||
this.regenerateHealth(deltaTimeInSeconds);
|
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);
|
this.groundPlanet?.registerPresence(this);
|
||||||
|
|
||||||
// The whole walking model — gravity gather, movement force, on/off-planet
|
// stepCharacterMovement is the shared simulation the client predicts with,
|
||||||
// branch, posture springs, body-momentum, and stepping the three parts —
|
// so the two can never drift.
|
||||||
// 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.
|
|
||||||
const direction = this.averageAndResetMovementActions();
|
const direction = this.averageAndResetMovementActions();
|
||||||
stepCharacterMovement(this, this.movementWorld, direction, deltaTimeInSeconds);
|
stepCharacterMovement(this, this.movementWorld, direction, deltaTimeInSeconds);
|
||||||
|
|
||||||
|
|
@ -502,10 +486,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
||||||
grounded = true;
|
grounded = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Brake with the exact shared model the living body uses: stiff on contact
|
// Brake with the shared model the living body uses, so a flung corpse
|
||||||
// so the corpse skids to rest, gentle in the air, plus the constant stop and
|
// comes to a definite stop instead of sliding forever.
|
||||||
// the speed cap — so a flung corpse comes to a definite stop instead of
|
|
||||||
// sliding forever.
|
|
||||||
decayMomentum(this.bodyVelocity, grounded, deltaTime);
|
decayMomentum(this.bodyVelocity, grounded, deltaTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||||
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
|
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
|
||||||
import { Physical } from '../physics/physicals/physical';
|
import { Physical } from '../physics/physicals/physical';
|
||||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
||||||
import { ReactToCollisionCommand } from '../commands/react-to-collision';
|
import { ReactToCollisionCommand } from '../commands/commands';
|
||||||
|
|
||||||
@serializesTo(Circle)
|
@serializesTo(Circle)
|
||||||
export class CirclePhysical extends CommandReceiver implements Circle, DynamicPhysical {
|
export class CirclePhysical extends CommandReceiver implements Circle, DynamicPhysical {
|
||||||
|
|
@ -35,8 +35,8 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh
|
||||||
private _radius: number,
|
private _radius: number,
|
||||||
public owner: GameObject,
|
public owner: GameObject,
|
||||||
private readonly container: PhysicalContainer,
|
private readonly container: PhysicalContainer,
|
||||||
// Public + readonly so a CirclePhysical structurally satisfies the shared
|
// Public + readonly so CirclePhysical satisfies the shared PhysicsBody
|
||||||
// PhysicsBody interface the movement simulation operates on.
|
// interface the movement simulation operates on.
|
||||||
public readonly restitution = 0,
|
public readonly restitution = 0,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
|
@ -89,14 +89,10 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh
|
||||||
applyForce(this, force, timeInSeconds);
|
applyForce(this, force, timeInSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position-resolution for one tick. Delegates to the shared
|
// Delegates to the shared resolveCircleMovement so the server integrates with
|
||||||
// resolveCircleMovement so the server integrates a body with the exact same
|
// the exact same geometry the client predictor runs — no parallel copy to
|
||||||
// geometry the client predictor runs (shared/physics) — no parallel copy to
|
// keep in sync. `possibleIntersectors` lets a caller that already broadphased
|
||||||
// keep in sync. The onHit callback dispatches the collision reactions at the
|
// avoid a second container query; otherwise it is self-gathered.
|
||||||
// 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.
|
|
||||||
public stepManually(
|
public stepManually(
|
||||||
deltaTimeInSeconds: number,
|
deltaTimeInSeconds: number,
|
||||||
possibleIntersectors?: Array<Physical>,
|
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();
|
this.syncBoundingBox();
|
||||||
|
|
||||||
return { hitObject: (hitObject as Physical | undefined)?.gameObject, velocity };
|
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> {
|
private sweptBroadphase(deltaTimeInSeconds: number): Array<Physical> {
|
||||||
const sweep = vec2.length(
|
const sweep = vec2.length(
|
||||||
vec2.scale(vec2.create(), this.velocity, deltaTimeInSeconds),
|
vec2.scale(vec2.create(), this.velocity, deltaTimeInSeconds),
|
||||||
);
|
);
|
||||||
// Grow the query, not the object: inflating and deflating this.radius ran
|
// Grow the query box, not this.radius: mutating the radius would re-register
|
||||||
// the setter (and so re-registered the body's extent) twice mid-tick.
|
// the body's extent mid-tick via the setter.
|
||||||
return this.container.findIntersecting(
|
return this.container.findIntersecting(
|
||||||
getBoundingBoxOfCircle(new Circle(this.center, this.radius + sweep)),
|
getBoundingBoxOfCircle(new Circle(this.center, this.radius + sweep)),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,11 @@ import {
|
||||||
CommandExecutors,
|
CommandExecutors,
|
||||||
CommandReceiver,
|
CommandReceiver,
|
||||||
} from 'shared';
|
} from 'shared';
|
||||||
import { GeneratePointsCommand } from '../commands/generate-points';
|
import {
|
||||||
import { AnnounceCommand } from '../commands/announce';
|
GeneratePointsCommand,
|
||||||
import { StepCommand } from '../commands/step';
|
AnnounceCommand,
|
||||||
|
StepCommand,
|
||||||
|
} from '../commands/commands';
|
||||||
|
|
||||||
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
|
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
|
||||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
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 {
|
export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
||||||
public readonly canCollide = true;
|
public readonly canCollide = true;
|
||||||
public readonly canMove = false;
|
public readonly canMove = false;
|
||||||
// Marks this as standable ground for the shared movement simulation (a body
|
// Marks this as standable ground for the shared movement simulation.
|
||||||
// landing on it latches it as currentPlanet). See shared GroundSurface.
|
|
||||||
public readonly isGround = true;
|
public readonly isGround = true;
|
||||||
|
|
||||||
public readonly sizePointMultiplier: number;
|
public readonly sizePointMultiplier: number;
|
||||||
|
|
||||||
// Planets slowly spin. The angle is authoritative here and streamed to the
|
// Authoritative spin angle, streamed to the client so the rendered outline
|
||||||
// client (see getPropertyUpdates), so the rendered outline and this collision
|
// and this collision polygon turn as one. cos/sin are memoised: distance()
|
||||||
// polygon turn as one rigid body. cos/sin are memoised per angle because
|
// is called many times per tick by the raymarcher and the angle rarely
|
||||||
// distance() is called many times per tick by the raymarcher and SDF sampling.
|
// changes between those calls.
|
||||||
private rotation = 0;
|
private rotation = 0;
|
||||||
private readonly rotationSpeed: number;
|
private readonly rotationSpeed: number;
|
||||||
private cachedRotation = Number.NaN;
|
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
|
// Characters standing on the planet this tick. Filled by registerPresence as
|
||||||
// each grounded character steps, drained when the planet resolves capture in
|
// 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 presentCharacters: Array<CharacterPhysical> = [];
|
||||||
private isContested = false;
|
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);
|
(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) {
|
public registerPresence(character: CharacterPhysical) {
|
||||||
this.presentCharacters.push(character);
|
this.presentCharacters.push(character);
|
||||||
}
|
}
|
||||||
|
|
||||||
public distance(target: vec2): number {
|
public distance(target: vec2): number {
|
||||||
// The one shared planet outline — the same function the client predictor
|
// The shared planet outline — the same function the client predictor
|
||||||
// collides against, which is what lets prediction reconcile. Only the
|
// collides against, which is what lets prediction reconcile.
|
||||||
// cos/sin memo lives here, because distance() is called many times per tick
|
|
||||||
// by the raymarcher and the angle rarely changes between those calls.
|
|
||||||
this.syncRotationTrigonometry();
|
this.syncRotationTrigonometry();
|
||||||
return planetDistance(
|
return planetDistance(
|
||||||
target,
|
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
|
// Signed angular velocity in rad/s, exposed so a character can ride the
|
||||||
// planet can ride its spin (see carryWithRotatingPlanet in shared).
|
// planet's spin (see carryWithRotatingPlanet in shared).
|
||||||
public get angularVelocity(): number {
|
public get angularVelocity(): number {
|
||||||
return this.rotationSpeed;
|
return this.rotationSpeed;
|
||||||
}
|
}
|
||||||
|
|
@ -145,9 +142,6 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
||||||
this.presentCharacters = [];
|
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) {
|
private resolveCapture(deltaTime: number) {
|
||||||
let blue = 0;
|
let blue = 0;
|
||||||
let red = 0;
|
let red = 0;
|
||||||
|
|
@ -168,14 +162,11 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
||||||
deltaTime * lead,
|
deltaTime * lead,
|
||||||
);
|
);
|
||||||
} else if (!occupied) {
|
} else if (!occupied) {
|
||||||
// Empty planets drift back to neutral; the keystone drifts much slower so
|
|
||||||
// it lingers as a live flashpoint.
|
|
||||||
this.takeControl(
|
this.takeControl(
|
||||||
CharacterTeam.neutral,
|
CharacterTeam.neutral,
|
||||||
this.isKeystone ? deltaTime / settings.keystoneLoseControlScale : deltaTime,
|
this.isKeystone ? deltaTime / settings.keystoneLoseControlScale : deltaTime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// occupied tie -> frozen tug-of-war: no ownership change, ring pulses.
|
|
||||||
|
|
||||||
const contested = occupied && net === 0;
|
const contested = occupied && net === 0;
|
||||||
if (contested !== this.isContested) {
|
if (contested !== this.isContested) {
|
||||||
|
|
@ -184,7 +175,6 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// hysteresis
|
|
||||||
private get flipTeam(): CharacterTeam {
|
private get flipTeam(): CharacterTeam {
|
||||||
const control = this.ownership - 0.5;
|
const control = this.ownership - 0.5;
|
||||||
const enter = settings.planetControlThreshold + settings.planetFlipHysteresis;
|
const enter = settings.planetControlThreshold + settings.planetFlipHysteresis;
|
||||||
|
|
@ -292,11 +282,6 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
||||||
return planetGravity(this.center, this.radius, position);
|
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 {
|
public get gameObject(): this {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,7 @@ import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||||
import { CharacterPhysical } from './character-physical';
|
import { CharacterPhysical } from './character-physical';
|
||||||
import { forceAtPosition } from '../physics/functions/force-at-position';
|
import { forceAtPosition } from '../physics/functions/force-at-position';
|
||||||
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
|
||||||
import { StepCommand } from '../commands/step';
|
import { StepCommand, ReactToCollisionCommand } from '../commands/commands';
|
||||||
import { ReactToCollisionCommand } from '../commands/react-to-collision';
|
|
||||||
|
|
||||||
@serializesTo(ProjectileBase)
|
@serializesTo(ProjectileBase)
|
||||||
export class ProjectilePhysical extends ProjectileBase implements DynamicPhysical {
|
export class ProjectilePhysical extends ProjectileBase implements DynamicPhysical {
|
||||||
|
|
@ -44,8 +43,8 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
|
||||||
private velocity: vec2,
|
private velocity: vec2,
|
||||||
public readonly originator: CharacterPhysical,
|
public readonly originator: CharacterPhysical,
|
||||||
readonly container: PhysicalContainer,
|
readonly container: PhysicalContainer,
|
||||||
// Normalised charge (0..1) of the shot that fired this, used by the victim
|
// Normalised charge (0..1) of the shot that fired this; the victim scales
|
||||||
// to scale hit/kill feedback and the death fling.
|
// hit/kill feedback and the death fling by it.
|
||||||
public readonly charge: number = 0,
|
public readonly charge: number = 0,
|
||||||
) {
|
) {
|
||||||
super(id(), center, radius, team, strength);
|
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
|
* 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
|
* travelling to the server. Capped by settings.maxProjectileCatchUpSeconds so
|
||||||
* than forcing them to lead by their own latency on top of the projectile's
|
* a forged timestamp cannot spawn a shot arbitrarily far downrange.
|
||||||
* travel time. Capped by settings.maxProjectileCatchUpSeconds so a bad or
|
|
||||||
* forged timestamp cannot spawn a shot arbitrarily far downrange.
|
|
||||||
*/
|
*/
|
||||||
public fastForward(seconds: number) {
|
public fastForward(seconds: number) {
|
||||||
const step = settings.targetPhysicsDeltaTimeInSeconds;
|
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
|
// Live view of the circle's box, not a snapshot — the circle keeps it in sync
|
||||||
// it, not a snapshot — caching it saved nothing and mistyped a mutable box as
|
// as it moves.
|
||||||
// immutable.
|
|
||||||
public get boundingBox(): BoundingBoxBase {
|
public get boundingBox(): BoundingBoxBase {
|
||||||
return this.object.boundingBox;
|
return this.object.boundingBox;
|
||||||
}
|
}
|
||||||
|
|
@ -155,9 +151,6 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
|
||||||
return;
|
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
|
// 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
|
// (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.
|
// the swept-collision box and the step needn't query the container again.
|
||||||
|
|
|
||||||
|
|
@ -6,3 +6,14 @@ export interface Options {
|
||||||
npcCount: number;
|
npcCount: number;
|
||||||
seed: 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 {
|
export abstract class BoundingBoxBase {
|
||||||
constructor(
|
constructor(
|
||||||
protected _xMin: number = 0,
|
protected _xMin: number = 0,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,6 @@ export const forceAtPosition = (position: vec2, objects: Array<Physical>) =>
|
||||||
.filter((o) => o instanceof PlanetPhysical)
|
.filter((o) => o instanceof PlanetPhysical)
|
||||||
.reduce(
|
.reduce(
|
||||||
(sum: vec2, o: Physical) =>
|
(sum: vec2, o: Physical) =>
|
||||||
vec2.add(sum, sum, (o as PlanetPhysical).getForce(position)),
|
vec2.add(sum, sum, (o as PlanetPhysical).gravityAt(position)),
|
||||||
vec2.create(),
|
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,
|
MoveActionCommand,
|
||||||
CharacterTeam,
|
CharacterTeam,
|
||||||
Id,
|
Id,
|
||||||
rotate90Deg,
|
|
||||||
} from 'shared';
|
} from 'shared';
|
||||||
import { PhysicalContainer } from '../physics/containers/physical-container';
|
import { PhysicalContainer } from '../physics/containers/physical-container';
|
||||||
import { PlayerContainer } from './player-container';
|
import { PlayerContainer } from './player-container';
|
||||||
|
|
@ -62,8 +61,6 @@ const npcTuning = {
|
||||||
spreadPerDistance: 0.08,
|
spreadPerDistance: 0.08,
|
||||||
spreadAggressionFalloff: 1.3,
|
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,
|
leapChancePerSecond: 0.35,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -259,7 +256,7 @@ export class NPC extends PlayerBase {
|
||||||
return undefined;
|
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);
|
const toMe = vec2.subtract(vec2.create(), this.center, threat.center);
|
||||||
if (vec2.dot(perpendicular, toMe) < 0) {
|
if (vec2.dot(perpendicular, toMe) < 0) {
|
||||||
vec2.negate(perpendicular, perpendicular);
|
vec2.negate(perpendicular, perpendicular);
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
} 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';
|
||||||
import { isCircleIntersecting } from '../physics/functions/is-circle-intersecting';
|
import { evaluateSdf } from 'shared';
|
||||||
import { CharacterPhysical } from '../objects/character-physical';
|
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';
|
||||||
|
|
@ -37,11 +37,9 @@ export abstract class PlayerBase extends CommandReceiver {
|
||||||
|
|
||||||
protected createCharacter() {
|
protected createCharacter() {
|
||||||
this.character = new CharacterPhysical(
|
this.character = new CharacterPhysical(
|
||||||
// Coerced, not just truncated. `.slice()` also exists on arrays, so a
|
// Coerce, don't just truncate: input is untrusted (from deserialize), and
|
||||||
// name that is an array passed straight through, was serialized into
|
// arrays have `.slice()` too — a non-string name would throw inside the
|
||||||
// CreateObjects, and was revived as a class on every peer that received
|
// reviver and kill the recipient's whole message batch.
|
||||||
// it — throwing inside the deserializer's reviver and killing that
|
|
||||||
// client's whole message batch.
|
|
||||||
sanitizeName(this.playerInfo.name, maximumNameLength),
|
sanitizeName(this.playerInfo.name, maximumNameLength),
|
||||||
this.sumKills,
|
this.sumKills,
|
||||||
this.sumDeaths,
|
this.sumDeaths,
|
||||||
|
|
@ -56,12 +54,8 @@ export abstract class PlayerBase extends CommandReceiver {
|
||||||
public abstract step(deltaTimeInSeconds: number): void;
|
public abstract step(deltaTimeInSeconds: number): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The death/respawn lifecycle both a connected player and an NPC go through:
|
* Shared death/respawn cycle. Returns the living character to act with this
|
||||||
* bank the score off a body that just died, hold the corpse for the respawn
|
* tick (or undefined), so a subclass can use it directly without re-narrowing.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
protected stepLifecycle(deltaTimeInSeconds: number): CharacterPhysical | undefined {
|
protected stepLifecycle(deltaTimeInSeconds: number): CharacterPhysical | undefined {
|
||||||
if (this.character) {
|
if (this.character) {
|
||||||
|
|
@ -87,8 +81,7 @@ export abstract class PlayerBase extends CommandReceiver {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hooks for what a subclass wants to do with the body it just lost, and just
|
// Override hooks (no-ops by default).
|
||||||
// before a new one is built. Both are no-ops by default.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
protected onCharacterDied(character: CharacterPhysical) {}
|
protected onCharacterDied(character: CharacterPhysical) {}
|
||||||
protected onBeforeRespawn() {}
|
protected onBeforeRespawn() {}
|
||||||
|
|
@ -120,8 +113,6 @@ 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) {
|
public restoreScore(kills: number, deaths: number) {
|
||||||
this.sumKills = kills;
|
this.sumKills = kills;
|
||||||
this.sumDeaths = deaths;
|
this.sumDeaths = deaths;
|
||||||
|
|
@ -141,7 +132,10 @@ export abstract class PlayerBase extends CommandReceiver {
|
||||||
const playerBoundingBox = getBoundingBoxOfCircle(playerBoundingCircle);
|
const playerBoundingBox = getBoundingBoxOfCircle(playerBoundingCircle);
|
||||||
const possibleIntersectors =
|
const possibleIntersectors =
|
||||||
this.objectContainer.findIntersecting(playerBoundingBox);
|
this.objectContainer.findIntersecting(playerBoundingBox);
|
||||||
if (!isCircleIntersecting(playerBoundingCircle, possibleIntersectors)) {
|
if (
|
||||||
|
evaluateSdf(playerBoundingCircle.center, possibleIntersectors) >=
|
||||||
|
playerBoundingCircle.radius
|
||||||
|
) {
|
||||||
return playerPosition;
|
return playerPosition;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue