Improve netcode and simplify (#4)
All checks were successful
Build & deploy / Build & deploy website (push) Successful in 32s
Build & deploy / Build & publish server image (push) Successful in 1m2s

- Simplify the logic wherever possible to remove overengineering and simplify the netcode
- Actually improve the netcode
- The new lines are mainly the package locks and tests

Reviewed-on: https://home.schmelczer.dev/git/andras/decla-red/pulls/4
This commit is contained in:
Andras Schmelczer 2026-09-15 07:57:57 +01:00
commit 93a7fe01ab
41 changed files with 15073 additions and 4754 deletions

3635
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -13,7 +13,7 @@
},
"scripts": {
"build": "npx webpack --mode production",
"dev": "concurrently --kill-others-on-fail \"webpack --mode development -w\" \"nodemon --legacy-watch dist/main.js\"",
"dev": "concurrently --kill-others-on-fail \"webpack --mode development -w\" \"nodemon --legacy-watch --delay 500ms dist/main.js\"",
"try-build": "npm run build && cd dist && node main.js && cd -"
},
"dependencies": {

View file

@ -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();
}
}

View file

@ -1,10 +0,0 @@
import { Command } from 'shared';
export class GeneratePointsCommand extends Command {
public constructor(
public readonly blue: number,
public readonly red: number,
) {
super();
}
}

View file

@ -1,7 +0,0 @@
import { Command, GameObject } from 'shared';
export class ReactToCollisionCommand extends Command {
public constructor(public readonly other: GameObject) {
super();
}
}

View file

@ -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();
}
}

View file

@ -2,16 +2,14 @@ import { vec2 } from 'gl-matrix';
import { Random, PlanetBase, hsl, settings, evaluateSdf } from 'shared';
import { LampPhysical } from './objects/lamp-physical';
import { PlanetPhysical } from './objects/planet-physical';
import { PhysicalContainer } from './physics/containers/physical-container';
import { Physical } from './physics/physicals/physical';
import { PhysicalContainer } from './physics/physical-container';
export const createWorld = (objectContainer: PhysicalContainer) => {
const objects: Array<Physical> = [];
const lights: Array<Physical> = [];
const planets: Array<PlanetPhysical> = [];
const lamps: Array<LampPhysical> = [];
for (let r = 0; r < settings.worldRadius; r += settings.radiusSteps) {
const circumference = 2 * Math.PI * r;
const stepCount = circumference * settings.objectsOnCircleLength;
const stepCount = 2 * Math.PI * r * settings.objectsOnCircleLength;
for (let rad = 0; rad < 2 * Math.PI; rad += (2 * Math.PI) / stepCount) {
const position = vec2.rotate(
vec2.create(),
@ -20,12 +18,12 @@ export const createWorld = (objectContainer: PhysicalContainer) => {
rad,
);
if (objects.length !== 0 && Random.getRandom() > 0.5) {
if (planets.length !== 0 && Random.getRandom() > 0.5) {
if (
evaluateSdf(position, objects) > 200 &&
!lights.find((l) => l.distance(position) < 2500)
evaluateSdf(position, planets) > 200 &&
!lamps.find((l) => l.distance(position) < 2500)
) {
lights.push(
lamps.push(
new LampPhysical(
position,
hsl(
@ -37,61 +35,43 @@ export const createWorld = (objectContainer: PhysicalContainer) => {
),
);
}
} else {
if (
evaluateSdf(position, objects) > 1400 &&
!lights.find((l) => l.distance(position) < 1700)
) {
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(
PlanetBase.createPlanetVertices(
position,
Random.getRandomInRange(1600, 2400),
Random.getRandomInRange(1600, 2400),
Random.getRandomInRange(80, 300),
),
true,
)
: new PlanetPhysical(
PlanetBase.createPlanetVertices(
position,
Random.getRandomInRange(300, 1600),
Random.getRandomInRange(300, 1600),
Random.getRandomInRange(20, 100),
),
);
objects.push(planet);
}
} else if (
evaluateSdf(position, planets) > 1400 &&
!lamps.find((l) => l.distance(position) < 1700)
) {
const isKeystone = planets.length === 0;
const [sizeMin, sizeMax, roughMin, roughMax] = isKeystone
? [1600, 2400, 80, 300]
: [300, 1600, 20, 100];
planets.push(
new PlanetPhysical(
PlanetBase.createPlanetVertices(
position,
Random.getRandomInRange(sizeMin, sizeMax),
Random.getRandomInRange(sizeMin, sizeMax),
Random.getRandomInRange(roughMin, roughMax),
),
isKeystone,
objectContainer.game,
),
);
}
}
}
console.info(`Generated ${objects.length} planets`);
console.info(`Generated ${lights.length} light`);
console.info(`Generated ${planets.length} planets and ${lamps.length} lamps`);
// 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.
const planets = objects.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical);
lights
.filter((l): l is LampPhysical => l instanceof LampPhysical)
.forEach((lamp) => {
let nearest: PlanetPhysical | undefined;
let nearestDistance = Infinity;
planets.forEach((planet) => {
const distance = planet.distance(lamp.center);
if (distance < nearestDistance) {
nearestDistance = distance;
nearest = planet;
}
});
nearest?.addLamp(lamp);
});
for (const lamp of lamps) {
let nearest: PlanetPhysical | undefined;
let nearestDistance = Infinity;
for (const planet of planets) {
const distance = planet.distance(lamp.center);
if (distance < nearestDistance) {
nearestDistance = distance;
nearest = planet;
}
}
nearest?.addLamp(lamp);
}
[...objects, ...lights].forEach((o) => objectContainer.addObject(o));
[...planets, ...lamps].forEach((o) => objectContainer.addObject(o));
};

View file

@ -1,10 +0,0 @@
import { Options } from './options';
export const defaultOptions: Options = {
port: 3000,
name: 'Test server',
playerLimit: 16,
npcCount: 8,
seed: Math.random(),
scoreLimit: 2500,
};

View file

@ -0,0 +1,17 @@
import { CharacterTeam } from 'shared';
export interface GameEvents {
addPoints(blue: number, red: number): void;
announce(text: string): void;
}
export const addPointsForTeam = (
game: GameEvents,
team: CharacterTeam,
points: number,
): void => {
game.addPoints(
team === CharacterTeam.blue ? points : 0,
team === CharacterTeam.red ? points : 0,
);
};

View file

@ -1,5 +1,6 @@
import { PhysicalContainer } from './physics/containers/physical-container';
import { Server, Socket } from 'socket.io';
import { randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import {
TransportEvents,
deserialize,
@ -11,25 +12,36 @@ import {
GameEndCommand,
GameStartCommand,
Command,
CommandReceiver,
CommandExecutors,
ServerAnnouncement,
JoinRejectionReason,
GameObject,
PropertyUpdatesForObject,
} from 'shared';
import { PhysicalContainer } from './physics/physical-container';
import { createWorld } from './create-world';
import { DeltaTimeCalculator } from './helper/delta-time-calculator';
import { Options } from './options';
import { PlayerContainer } from './players/player-container';
import { StepCommand } from './commands/step';
import { GeneratePointsCommand } from './commands/generate-points';
import { AnnounceCommand } from './commands/announce';
import { GameEvents } from './game-events';
import { CarriedScore, PlayerContainer } from './players/player-container';
import { Player } from './players/player';
const gameStateSubscribedRoom = 'gameStateSubscribedRoom';
const endTitleSeconds = 6;
export class GameServer extends CommandReceiver {
const asPlayerInformation = (payload: unknown): PlayerInformation | undefined => {
if (!payload || typeof payload !== 'object') {
return undefined;
}
const { name, reconnectToken } = payload as Record<string, unknown>;
return {
name: name as string,
reconnectToken: typeof reconnectToken === 'string' ? reconnectToken : undefined,
};
};
export class GameServer implements GameEvents {
private objects!: PhysicalContainer;
private players!: PlayerContainer;
private deltaTimes!: Array<number>;
private deltaTimeCalculator!: DeltaTimeCalculator;
private lastPhysicsBase = process.hrtime.bigint();
private bluePoints = 0;
private redPoints = 0;
@ -37,92 +49,202 @@ export class GameServer extends CommandReceiver {
private isInEndGame = false;
private timeScaling = 1;
private statReportMs = Date.now();
private timeSinceLastServerStateUpdate = 0;
private timeSinceLastPointUpdate = 0;
private physicsAccumulator = 0;
private saturatedFrames = 0;
private droppedInboundMessages = 0;
private serverName: string;
private playerLimit: number;
private initialize() {
const previousPlayers = this.players;
this.objects = new PhysicalContainer();
createWorld(this.objects);
this.objects.initialize();
this.players = new PlayerContainer(
this.objects,
this.options.playerLimit,
this.options.npcCount,
);
this.deltaTimeCalculator = new DeltaTimeCalculator();
this.deltaTimes = [];
this.bluePoints = 0;
this.redPoints = 0;
this.matchPointAnnounced = {};
this.isInEndGame = false;
this.timeScaling = 1;
previousPlayers?.queueCommandForEachClient(new GameStartCommand());
previousPlayers?.sendQueuedCommands();
}
protected commandExecutors: CommandExecutors = {
[GeneratePointsCommand.type]: this.addPoints.bind(this),
[AnnounceCommand.type]: ({ text }: AnnounceCommand) =>
this.players.queueCommandForEachClient(new ServerAnnouncement(text)),
};
private readonly joinedSockets = new Map<
Socket,
{ player: Player; release: () => void }
>();
private inboundBudget = new WeakMap<Socket, { tokens: number; lastMs: number }>();
constructor(
private readonly io: Server,
private options: Options,
) {
super();
this.serverName = options.name;
this.playerLimit = options.playerLimit;
this.initialize();
io.on('connection', (socket: Socket) => {
socket.on(TransportEvents.PlayerJoining, (playerInfo: PlayerInformation) => {
socket.on(TransportEvents.PlayerJoining, (payload: unknown) => {
if (!this.allowInboundMessage(socket)) {
return;
}
try {
const player = this.players.createPlayer(playerInfo, socket);
socket.on(TransportEvents.PlayerToServer, (json: string) => {
try {
const commands: Array<Command> = deserialize(json);
commands.forEach((c) => player.handleCommand(c));
} catch (e) {
console.error('Error while processing command', e);
}
});
this.sendServerStateUpdate();
socket.on('disconnect', () => {
player.destroy();
this.players.deletePlayer(player);
this.sendServerStateUpdate();
});
this.handleJoin(socket, payload);
} catch (e) {
console.error('Failed to register joining player; disconnecting socket', e);
console.error('Failed to handle a join request', e);
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.InvalidRequest);
socket.disconnect();
}
});
socket.on(TransportEvents.SubscribeForServerInfoUpdates, () => {
socket.join(gameStateSubscribedRoom);
if (this.allowInboundMessage(socket)) {
socket.join(gameStateSubscribedRoom);
}
});
});
}
private timeSinceLastServerStateUpdate = 0;
private initialize() {
const previousPlayers = this.players;
this.joinedSockets.forEach(({ release }) => release());
this.joinedSockets.clear();
this.inboundBudget = new WeakMap();
this.objects = new PhysicalContainer(this);
createWorld(this.objects);
this.players = new PlayerContainer(
this.objects,
this.options.playerLimit,
this.options.npcCount,
);
this.lastPhysicsBase = process.hrtime.bigint();
this.bluePoints = 0;
this.redPoints = 0;
this.matchPointAnnounced = {};
this.isInEndGame = false;
this.timeScaling = 1;
this.physicsAccumulator = 0;
this.timeSinceLastServerStateUpdate = 0;
this.timeSinceLastPointUpdate = 0;
previousPlayers?.queueCommandForEachClient(new GameStartCommand());
previousPlayers?.sendQueuedCommands();
}
private handleJoin(socket: Socket, payload: unknown) {
const playerInfo = asPlayerInformation(payload);
if (!playerInfo) {
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.InvalidRequest);
return;
}
if (this.joinedSockets.has(socket)) {
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.AlreadyJoined);
return;
}
if (this.isInEndGame) {
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.RoundEnding);
return;
}
const carried = this.retireGhost(playerInfo.reconnectToken);
if (this.players.isFull) {
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.ServerFull);
return;
}
const player = this.players.createPlayer(playerInfo, socket, carried);
const onPlayerToServer = (json: unknown) => {
try {
if (typeof json !== 'string' || json.length > settings.maxInboundMessageBytes) {
return;
}
if (!this.allowInboundMessage(socket)) {
return;
}
const commands: Array<Command> = deserialize(json);
if (!Array.isArray(commands)) {
return;
}
commands.forEach((c) => player.handleCommand(c));
} catch (e) {
console.error('Error while processing command', e);
}
};
const onDisconnect = () => {
this.removeJoined(socket);
this.sendServerStateUpdate();
};
socket.on(TransportEvents.PlayerToServer, onPlayerToServer);
socket.on('disconnect', onDisconnect);
this.joinedSockets.set(socket, {
player,
release: () => {
socket.off(TransportEvents.PlayerToServer, onPlayerToServer);
socket.off('disconnect', onDisconnect);
player.detachFromSocket();
},
});
player.reconnectToken = randomUUID();
socket.emit(TransportEvents.PlayerJoined, player.reconnectToken);
this.sendServerStateUpdate();
}
private allowInboundMessage(socket: Socket): boolean {
// performance.now(), not Date.now(): a backwards wall-clock step would lock the client out.
const nowMs = performance.now();
const burst = settings.maxInboundMessageBurst;
const perSecond = settings.maxInboundMessagesPerSecond;
const budget = this.inboundBudget.get(socket) ?? { tokens: burst, lastMs: nowMs };
budget.tokens = Math.min(
burst,
budget.tokens + (Math.max(0, nowMs - budget.lastMs) / 1000) * perSecond,
);
budget.lastMs = nowMs;
this.inboundBudget.set(socket, budget);
if (budget.tokens < 1) {
this.droppedInboundMessages++;
return false;
}
budget.tokens -= 1;
return true;
}
private retireGhost(token?: string): CarriedScore | undefined {
if (!token) {
return undefined;
}
for (const [socket, { player }] of this.joinedSockets) {
if (player.reconnectToken === token) {
const carried = { team: player.team, ...player.score };
this.removeJoined(socket);
return carried;
}
}
return undefined;
}
private removeJoined(socket: Socket) {
const joined = this.joinedSockets.get(socket);
if (!joined) {
return;
}
this.joinedSockets.delete(socket);
this.inboundBudget.delete(socket);
joined.release();
joined.player.destroy();
this.players.deletePlayer(joined.player);
}
public sendServerStateUpdate() {
this.io
.to(gameStateSubscribedRoom)
.emit(TransportEvents.ServerInfoUpdate, [this.players.count, this.gameProgress]);
.emit(TransportEvents.ServerInfoUpdate, [
this.players.count,
this.serverInfo.gameStatePercent,
]);
}
public start() {
this.handlePhysics();
}
private addPoints({ blue, red }: GeneratePointsCommand) {
public addPoints(blue: number, red: number) {
if (this.isInEndGame) {
return;
}
@ -139,45 +261,60 @@ export class GameServer extends CommandReceiver {
}
}
public announce(text: string) {
this.players.queueCommandForEachClient(new ServerAnnouncement(text));
}
private announceMatchPointOnce(team: CharacterTeam, points: number) {
if (
!this.matchPointAnnounced[team] &&
points >= this.options.scoreLimit * settings.matchPointScoreRatio
) {
this.matchPointAnnounced[team] = true;
this.players.queueCommandForEachClient(
new ServerAnnouncement(
`Match point — team <span class="${team}">${team}</span>!`,
),
);
this.announce(`Match point — team <span class="${team}">${team}</span>!`);
}
}
private endGame(winningTeam: CharacterTeam) {
this.isInEndGame = true;
const endTitleLength = 6;
this.players.endGame(winningTeam);
this.players.queueCommandForEachClient(
new GameEndCommand(winningTeam, endTitleLength, true),
);
setTimeout(() => this.destroy(), endTitleLength * 1000 * 1.1);
this.players.queueCommandForEachClient(new GameEndCommand());
setTimeout(() => this.initialize(), endTitleSeconds * 1000 * 1.1);
}
private destroy() {
this.initialize();
}
private reportStats() {
const mem = `${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`;
console.info(`Memory: ${mem}, Players: ${this.players.count}`);
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;
const rtts = this.players.connectedPlayerRttsMs.filter((r) => r > 0);
if (rtts.length > 0) {
rtts.sort((a, b) => a - b);
console.info(
`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 msg(s)`);
this.droppedInboundMessages = 0;
}
if (this.saturatedFrames > 0) {
console.warn(`Physics saturated on ${this.saturatedFrames} frame(s)`);
this.saturatedFrames = 0;
}
}
private handlePhysics() {
const delta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds({ setAsBase: true });
this.deltaTimes.push(delta);
const frameStart = process.hrtime.bigint();
const frameStartMs = performance.now();
const delta = Number(frameStart - this.lastPhysicsBase) / 1e9;
this.lastPhysicsBase = frameStart;
this.handleStats();
if (Date.now() - this.statReportMs > 30000) {
this.statReportMs = Date.now();
this.reportStats();
}
if ((this.timeSinceLastServerStateUpdate += delta) > 4) {
this.timeSinceLastServerStateUpdate = 0;
@ -193,18 +330,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,
@ -221,69 +351,41 @@ export class GameServer extends CommandReceiver {
this.timeScaling *= Math.pow(settings.endGameDeltaScaling, fixedDelta);
scaledDelta /= this.timeScaling;
}
this.objects.handleCommand(new StepCommand(scaledDelta, this));
this.objects.step(scaledDelta);
this.players.step(scaledDelta);
}
this.players.stepCommunication(delta);
this.objects.resetRemoteCalls();
// Physics has only advanced to the start of this frame less whatever is still in the
// accumulator. Timing anything off the send time instead would put up to one physics step
// of noise on every interpolated position and on the client's replay anchor.
const simulatedThroughMs = frameStartMs - this.physicsAccumulator * 1000;
const physicsDelta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds();
setTimeout(
this.handlePhysics.bind(this),
Math.max(0, fixedDelta - physicsDelta) * 1000,
);
}
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`,
if (substeps > 0) {
const propertyUpdates = new Map<GameObject, PropertyUpdatesForObject | undefined>();
this.players.stepCommunication(
substeps * fixedDelta,
simulatedThroughMs,
(object) => {
if (!propertyUpdates.has(object)) {
propertyUpdates.set(object, object.getPropertyUpdates(1 / this.timeScaling));
}
return propertyUpdates.get(object);
},
);
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.saturatedFrames > 0) {
console.warn(
`Physics saturated on ${this.saturatedFrames} frame(s) since last report — shedding backlog`,
);
this.saturatedFrames = 0;
}
this.deltaTimes = [];
this.objects.resetRemoteCalls();
}
}
private get gameProgress(): number {
return (Math.max(this.bluePoints, this.redPoints) / this.options.scoreLimit) * 100;
const elapsed = Number(process.hrtime.bigint() - frameStart) / 1e9;
setTimeout(() => this.handlePhysics(), Math.max(0, fixedDelta - elapsed) * 1000);
}
public get serverInfo(): ServerInformation {
return {
serverName: this.serverName,
serverName: this.options.name,
playerCount: this.players.count,
playerLimit: this.playerLimit,
gameStatePercent: this.gameProgress,
playerLimit: this.options.playerLimit,
gameStatePercent:
(Math.max(this.bluePoints, this.redPoints) / this.options.scoreLimit) * 100,
};
}
}

View file

@ -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;
}
}

View file

@ -2,15 +2,14 @@ import { Server as IoServer } from 'socket.io';
import express from 'express';
import { Server } from 'http';
import cors from 'cors';
import { applyArrayPlugins, Random, serverInformationEndpoint } from 'shared';
import { Random, serverInformationEndpoint, settings } from 'shared';
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);
applyArrayPlugins();
const optionOverrides = minimist(process.argv.slice(2));
const options = {
@ -24,6 +23,7 @@ const app = express();
const server = new Server(app);
const io = new IoServer(server, {
parser,
maxHttpBufferSize: settings.maxInboundMessageBytes,
cors: {
origin: true,
credentials: true,
@ -32,14 +32,7 @@ const io = new IoServer(server, {
const gameServer = new GameServer(io, options);
app.use(
cors({
origin: (_, callback) => {
callback(null, true);
},
credentials: true,
}),
);
app.use(cors({ origin: true, credentials: true }));
app.get(serverInformationEndpoint, (_, res) => {
res.json(gameServer.serverInfo);

View file

@ -2,22 +2,23 @@ import { vec2 } from 'gl-matrix';
import {
id,
settings,
MoveActionCommand,
serializesTo,
last,
Circle,
CharacterBase,
CharacterTeam,
PropertyUpdatesForObject,
UpdatePropertyCommand,
CommandExecutors,
CommandReceiver,
CharacterMovementSnapshot,
GameObject,
mix,
clamp01,
strengthToCharge,
stepCharacterMovement,
applyForce,
applyLeapImpulse,
decayMomentum,
CharacterMovementState,
tickPlanetDetachment,
characterCenter,
sumGravity,
CharacterWorld,
GroundSurface,
headRadius,
@ -27,69 +28,45 @@ import {
rightFootOffset,
boundRadius,
} from 'shared';
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
import { addPointsForTeam } from '../game-events';
import { Physical } from '../physics/physical';
import { CirclePhysical } from './circle-physical';
import { PhysicalContainer } from '../physics/containers/physical-container';
import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base';
import { PhysicalContainer } from '../physics/physical-container';
import { BoundingBox } from '../physics/bounding-box';
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 { PlanetPhysical, planetsNear } from './planet-physical';
@serializesTo(CharacterBase)
export class CharacterPhysical extends CharacterBase implements DynamicPhysical {
const placeholder = new Circle(vec2.create(), 0);
export class CharacterPhysical extends CharacterBase implements Physical {
public readonly canCollide = true;
public readonly canMove = true;
private projectileStrength = settings.playerMaxStrength;
// Body geometry (head/foot radii, posture offsets, bound radius) is defined
// once in the shared movement module and imported here, so the authoritative
// body and the client's predicted body are bit-identical by construction
// instead of by a hand-synced "copied verbatim" duplicate. Re-exposed as a
// static only because external callers reference CharacterPhysical.boundRadius.
public static readonly boundRadius = boundRadius;
private timeSinceDying = 0;
private isDestroyed = false;
private timeSinceBorn = 0;
private hasJustBorn = true;
private timeAlive = 0;
private timeSinceLastShot = settings.projectileCreationInterval;
private timeSinceLastDamage = settings.playerOutOfCombatDelaySeconds;
private lastSyncedHealth = settings.playerMaxHealth;
private killStreak = 0;
private direction = 0;
private currentPlanet?: PlanetPhysical;
private secondsSinceOnSurface = settings.planetDetachmentSeconds;
private bodyVelocity = vec2.create();
private timeSinceLastLeap = settings.leapCooldownSeconds;
public head: CirclePhysical;
public leftFoot: CirclePhysical;
public rightFoot: CirclePhysical;
private movementState!: CharacterMovementState;
private movementWorld!: CharacterWorld;
public direction = 0;
public currentPlanet: GroundSurface | undefined;
public secondsSinceOnSurface = settings.planetDetachmentSeconds;
public readonly bodyVelocity = vec2.create();
private movementActions: Array<MoveActionCommand> = [];
private lastMovementAction: MoveActionCommand = new MoveActionCommand(vec2.create());
private projectileStrength = settings.playerMaxStrength;
private timeAlive = 0;
private timeSinceDying = 0;
private isDestroyed = false;
private hasJustBorn = true;
private hasFiredSinceSpawn = false;
private timeSinceLastShot = settings.projectileCreationInterval;
private timeSinceLastLeap = settings.leapCooldownSeconds;
private timeSinceLastDamage = settings.playerOutOfCombatDelaySeconds;
private lastSyncedHealth = settings.playerMaxHealth;
private killStreak = 0;
private headVelocity = new Circle(vec2.create(), 0);
private leftFootVelocity = new Circle(vec2.create(), 0);
private rightFootVelocity = new Circle(vec2.create(), 0);
private readonly movementDirection = vec2.create();
protected commandExecutors: CommandExecutors = {
[StepCommand.type]: this.step.bind(this),
[ReactToCollisionCommand.type]: this.onCollision.bind(this),
};
private readonly previousPose = [0, 1, 2].map(() => new Circle(vec2.create(), 0));
private readonly velocities = [0, 1, 2].map(() => new Circle(vec2.create(), 0));
private readonly box = new BoundingBox();
constructor(
name: string,
@ -99,112 +76,93 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
private readonly container: PhysicalContainer,
startPosition: vec2,
) {
super(id(), name, killCount, deathCount, team, settings.playerMaxHealth);
this.head = new CirclePhysical(
vec2.add(vec2.create(), startPosition, headOffset),
headRadius,
this,
container,
super(
id(),
name,
killCount,
deathCount,
team,
settings.playerMaxHealth,
placeholder,
placeholder,
placeholder,
);
this.leftFoot = new CirclePhysical(
vec2.add(vec2.create(), startPosition, leftFootOffset),
feetRadius,
this,
container,
);
this.rightFoot = new CirclePhysical(
vec2.add(vec2.create(), startPosition, rightFootOffset),
feetRadius,
this,
container,
);
container.addObject(this.head);
container.addObject(this.leftFoot);
container.addObject(this.rightFoot);
this.initMovementBridge();
const part = (offset: vec2, radius: number) =>
new CirclePhysical(
vec2.add(vec2.create(), startPosition, offset),
radius,
this,
container,
);
this.head = part(headOffset, headRadius);
this.leftFoot = part(leftFootOffset, feetRadius);
this.rightFoot = part(rightFootOffset, feetRadius);
}
private initMovementBridge() {
// The movementState object-literal getters/setters below can't use `this`
// (it would bind to the literal), so alias the character instance.
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;
this.movementState = {
head: this.head,
leftFoot: this.leftFoot,
rightFoot: this.rightFoot,
get direction() {
return self.direction;
},
set direction(value: number) {
self.direction = value;
},
get currentPlanet() {
return self.currentPlanet;
},
set currentPlanet(value: GroundSurface | undefined) {
// On the server every ground is a PlanetPhysical (the world only ever
// hands back planets), so this narrowing is safe.
self.currentPlanet = value as PlanetPhysical | undefined;
},
get secondsSinceOnSurface() {
return self.secondsSinceOnSurface;
},
set secondsSinceOnSurface(value: number) {
self.secondsSinceOnSurface = value;
},
bodyVelocity: this.bodyVelocity,
};
private readonly movementWorld: CharacterWorld = {
groundsNear: (center, radius) => planetsNear(this.container, center, radius),
stepBody: (body, deltaTimeInSeconds) => {
const hit = (body as CirclePhysical).stepManually(deltaTimeInSeconds);
return hit instanceof PlanetPhysical ? hit : undefined;
},
};
this.movementWorld = {
// Same set and order forceAtPosition used: planets in the force field,
// in container-traversal order (so the f64 gravity sum is unchanged).
groundsNear: (center, radius) =>
self.container
.findIntersecting(getBoundingBoxOfCircle(new Circle(center, radius)))
.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical),
stepBody: (body, deltaTimeInSeconds) => {
const { hitObject } = (body as CirclePhysical).stepManually(deltaTimeInSeconds);
return hitObject instanceof PlanetPhysical ? hitObject : undefined;
},
};
private get parts(): Array<CirclePhysical> {
return [this.head, this.leftFoot, this.rightFoot];
}
private get isSpawnProtected(): boolean {
return (
!this.hasFiredSinceSpawn &&
this.timeAlive <
settings.spawnDespawnTime + settings.spawnInvulnerabilityExtraSeconds
settings.spawnDespawnTime + settings.spawnInvulnerabilityExtraSeconds
);
}
private hasGeneratedPoints = false;
private getPoints(game: CommandReceiver) {
if (!this.isAlive && !this.hasGeneratedPoints) {
this.hasGeneratedPoints = true;
const blue = this.team === CharacterTeam.blue ? 0 : settings.playerKillPoint;
const red = this.team === CharacterTeam.red ? 0 : settings.playerKillPoint;
game.handleCommand(new GeneratePointsCommand(blue, red));
}
}
public get isAlive(): boolean {
return !this.isDestroyed;
}
public handleMovementAction(c: MoveActionCommand) {
this.movementActions.push(c);
}
public get groundPlanet(): PlanetPhysical | undefined {
return this.currentPlanet;
return this.currentPlanet as PlanetPhysical | undefined;
}
// Persistent launch momentum, streamed to the owning client so its predictor
// can reproduce a leap/slingshot/recoil flight instead of only snapping to it.
public get launchMomentum(): vec2 {
return this.bodyVelocity;
public get movementSnapshot(): CharacterMovementSnapshot {
return new CharacterMovementSnapshot(
this.direction,
vec2.clone(this.bodyVelocity),
vec2.clone(this.leftFoot.lastNormal),
vec2.clone(this.rightFoot.lastNormal),
this.groundPlanet?.id ?? null,
this.secondsSinceOnSurface,
Math.max(0, settings.leapCooldownSeconds - this.timeSinceLastLeap),
);
}
public get boundingBox(): BoundingBox {
return this.box.setCircle(this.center, boundRadius);
}
public get gameObject(): this {
return this;
}
public get center(): vec2 {
return characterCenter(this.head, this.leftFoot, this.rightFoot);
}
public distance(target: vec2): number {
return (
Math.min(
this.head.distance(target),
this.leftFoot.distance(target),
this.rightFoot.distance(target),
) - 5
);
}
public setMoveDirection(direction: vec2) {
vec2.normalize(this.movementDirection, direction);
}
public addKill(victimName: string, charge = 0) {
@ -212,11 +170,13 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
this.killStreak++;
this.remoteCall('setKillCount', this.killCount);
this.health = Math.min(
settings.playerMaxHealth,
this.health + settings.playerKillHealthReward,
);
this.syncHealth();
if (this.isAlive) {
this.health = Math.min(
settings.playerMaxHealth,
this.health + settings.playerKillHealthReward,
);
this.syncHealth();
}
this.remoteCall('onKillConfirmed', victimName, this.killStreak, charge);
}
@ -232,39 +192,38 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
}
}
public onCollision({ other }: ReactToCollisionCommand) {
public onCollision(other: GameObject) {
if (
// A corpse keeps its collidable circles for the despawn animation; the
// isAlive guard stops a flying corpse from eating shots aimed past it.
this.isAlive &&
other instanceof ProjectilePhysical &&
other.team !== this.team &&
other.isAlive
!this.isAlive ||
!(other instanceof ProjectilePhysical) ||
other.team === this.team ||
!other.isAlive
) {
other.destroy();
return;
}
if (this.isSpawnProtected) {
return;
}
other.destroy();
this.timeSinceLastDamage = 0;
this.health -= other.strength;
this.lastSyncedHealth = Math.round(this.health);
this.remoteCall('setHealth', this.health);
if (this.isSpawnProtected) {
return;
}
if (this.health <= 0 && this.isAlive) {
// Throw the corpse along the killing shot, harder for charged hits.
vec2.scaleAndAdd(
this.bodyVelocity,
this.bodyVelocity,
other.direction,
mix(settings.deathImpulseMin, settings.deathImpulseMax, other.charge),
);
this.onDie();
other.originator.addKill(this.name, other.charge);
} else {
other.originator.registerHit(other.charge);
}
this.timeSinceLastDamage = 0;
this.health -= other.strength;
this.lastSyncedHealth = Math.round(this.health);
this.remoteCall('setHealth', this.health);
if (this.health <= 0) {
vec2.scaleAndAdd(
this.bodyVelocity,
this.bodyVelocity,
other.direction,
mix(settings.deathImpulseMin, settings.deathImpulseMax, other.charge),
);
this.onDie(true);
other.originator.addKill(this.name, other.charge);
} else {
other.originator.registerHit(other.charge);
}
}
@ -277,42 +236,45 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
return;
}
const direction = vec2.subtract(vec2.create(), position, this.center);
if (vec2.length(direction) === 0) {
return;
}
this.timeSinceLastShot = 0;
this.hasFiredSinceSpawn = true;
const c = clamp01(charge);
const desiredStrength = mix(
settings.chargeShotStrengthMin,
settings.chargeShotStrengthMax,
c,
clamp01(charge),
);
const strength = Math.min(desiredStrength, this.projectileStrength);
this.projectileStrength -= strength;
const c = strengthToCharge(strength);
const radius = mix(settings.chargeShotRadiusMin, settings.chargeShotRadiusMax, c);
const speed = mix(settings.chargeShotSpeedMin, settings.chargeShotSpeedMax, c);
const direction = vec2.subtract(vec2.create(), position, this.center);
vec2.normalize(direction, direction);
// Keep the unit direction before vec2.scale repurposes it as the velocity.
const shotDirection = vec2.clone(direction);
const velocity = vec2.scale(direction, direction, speed);
const projectile = new ProjectilePhysical(
vec2.clone(this.center),
radius,
strength,
this.team,
velocity,
this,
this.container,
c,
this.container.addObject(
new ProjectilePhysical(
vec2.clone(this.center),
radius,
strength,
this.team,
vec2.scale(vec2.create(), direction, speed),
this,
this.container,
c,
),
);
this.container.addObject(projectile);
if (c > 0) {
vec2.scaleAndAdd(
this.bodyVelocity,
this.bodyVelocity,
shotDirection,
direction,
-settings.chargeShotRecoilMax * c,
);
}
@ -333,210 +295,89 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
this.timeSinceLastLeap = 0;
this.projectileStrength -= settings.leapStrengthCost;
// Same impulse the client predicts with (shared), so a leap launches
// identically on both sides.
applyLeapImpulse(this.movementState, this.lastMovementAction.direction);
applyLeapImpulse(this, this.movementDirection);
this.remoteCall('onLeap');
}
public get boundingBox(): BoundingBoxBase {
return getBoundingBoxOfCircle(new Circle(this.center, CharacterPhysical.boundRadius));
}
public get gameObject(): this {
return this;
}
public get center(): vec2 {
const bodyCenter = vec2.add(vec2.create(), this.head.center, this.leftFoot.center);
vec2.add(bodyCenter, bodyCenter, this.rightFoot.center);
return vec2.scale(bodyCenter, bodyCenter, 1 / 3);
}
public distance(target: vec2): number {
return (
Math.min(
this.head.distance(target),
this.leftFoot.distance(target),
this.rightFoot.distance(target),
) - 5
);
}
private averageAndResetMovementActions(): vec2 {
let direction: vec2;
if (this.movementActions.length === 0) {
direction = vec2.clone(this.lastMovementAction.direction);
} else {
direction = this.movementActions.reduce(
(sum, current) => vec2.add(sum, sum, current.direction),
vec2.create(),
);
vec2.scale(direction, direction, 1 / this.movementActions.length);
this.lastMovementAction = last(this.movementActions)!;
this.movementActions = [];
public onDie(killedInCombat = false) {
if (this.isDestroyed) {
return;
}
this.isDestroyed = true;
this.remoteCall('onDie');
return vec2.length(direction) > 0
? vec2.normalize(direction, direction)
: vec2.create();
if (killedInCombat) {
const opposingTeam =
this.team === CharacterTeam.blue ? CharacterTeam.red : CharacterTeam.blue;
addPointsForTeam(this.container.game, opposingTeam, settings.playerKillPoint);
}
}
private animateScaling(q: number) {
this.head.radius = headRadius * q;
this.leftFoot.radius = this.rightFoot.radius = feetRadius * q;
}
public getPropertyUpdates(): PropertyUpdatesForObject {
public getPropertyUpdates(timeScale = 1): PropertyUpdatesForObject {
const [headVelocity, leftFootVelocity, rightFootVelocity] = this.velocities.map(
(velocity) =>
new Circle(
vec2.scale(vec2.create(), velocity.center, timeScale),
velocity.radius * timeScale,
),
);
return new PropertyUpdatesForObject(this.id, [
new UpdatePropertyCommand('head', this.head, this.headVelocity),
new UpdatePropertyCommand('leftFoot', this.leftFoot, this.leftFootVelocity),
new UpdatePropertyCommand('rightFoot', this.rightFoot, this.rightFootVelocity),
new UpdatePropertyCommand('head', this.head, headVelocity),
new UpdatePropertyCommand('leftFoot', this.leftFoot, leftFootVelocity),
new UpdatePropertyCommand('rightFoot', this.rightFoot, rightFootVelocity),
new UpdatePropertyCommand(
'strength',
this.projectileStrength,
settings.playerStrengthRegenerationPerSeconds,
settings.playerStrengthRegenerationPerSeconds * timeScale,
),
]);
}
private setPropertyUpdates(
oldHead: Circle,
oldLeftFoot: Circle,
oldRightFoot: Circle,
deltaTime: number,
) {
this.headVelocity = new Circle(
vec2.scale(
oldHead.center,
vec2.subtract(oldHead.center, this.head.center, oldHead.center),
1 / deltaTime,
),
(this.head.radius - oldHead.radius) / deltaTime,
);
this.leftFootVelocity = new Circle(
vec2.scale(
oldLeftFoot.center,
vec2.subtract(oldLeftFoot.center, this.leftFoot.center, oldLeftFoot.center),
1 / deltaTime,
),
(this.leftFoot.radius - oldLeftFoot.radius) / deltaTime,
);
this.rightFootVelocity = new Circle(
vec2.scale(
oldRightFoot.center,
vec2.subtract(oldRightFoot.center, this.rightFoot.center, oldRightFoot.center),
1 / deltaTime,
),
(this.rightFoot.radius - oldRightFoot.radius) / deltaTime,
);
this.animateScaling(1);
}
private step({ deltaTimeInSeconds, game }: StepCommand) {
this.getPoints(game);
public step(deltaTimeInSeconds: number) {
this.timeAlive += deltaTimeInSeconds;
this.timeSinceLastLeap += deltaTimeInSeconds;
const oldHead = new Circle(vec2.clone(this.head.center), this.head.radius);
const oldLeftFoot = new Circle(
vec2.clone(this.leftFoot.center),
this.leftFoot.radius,
);
const oldRightFoot = new Circle(
vec2.clone(this.rightFoot.center),
this.rightFoot.radius,
);
this.parts.forEach((part, i) => {
vec2.copy(this.previousPose[i].center, part.center);
this.previousPose[i].radius = part.radius;
});
if (this.isDestroyed) {
if ((this.timeSinceDying += deltaTimeInSeconds) > settings.spawnDespawnTime) {
this.destroy();
this.container.removeObject(this);
} else {
this.freeFallCorpse(deltaTimeInSeconds);
this.animateScaling(1 - this.timeSinceDying / settings.spawnDespawnTime);
}
this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds);
return;
}
if (this.hasJustBorn) {
if ((this.timeSinceBorn += deltaTimeInSeconds) > settings.spawnDespawnTime) {
} else if (this.hasJustBorn) {
if (this.timeAlive > settings.spawnDespawnTime) {
this.hasJustBorn = false;
this.animateScaling(1);
} else {
this.animateScaling(this.timeSinceBorn / settings.spawnDespawnTime);
this.animateScaling(this.timeAlive / settings.spawnDespawnTime);
}
this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds);
return;
} else {
this.stepAlive(deltaTimeInSeconds);
}
if (
(this.secondsSinceOnSurface += deltaTimeInSeconds) >
settings.planetDetachmentSeconds
) {
this.currentPlanet = undefined;
}
this.parts.forEach((part, i) => {
const velocity = this.velocities[i];
const previous = this.previousPose[i];
vec2.subtract(velocity.center, part.center, previous.center);
vec2.scale(velocity.center, velocity.center, 1 / deltaTimeInSeconds);
velocity.radius = (part.radius - previous.radius) / deltaTimeInSeconds;
});
}
private stepAlive(deltaTimeInSeconds: number) {
tickPlanetDetachment(this, deltaTimeInSeconds);
this.timeSinceLastShot += deltaTimeInSeconds;
this.projectileStrength = Math.min(
settings.playerMaxStrength,
this.projectileStrength +
settings.playerStrengthRegenerationPerSeconds * 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.currentPlanet?.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.
const direction = this.averageAndResetMovementActions();
stepCharacterMovement(
this.movementState,
this.movementWorld,
direction,
deltaTimeInSeconds,
);
this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds);
}
private freeFallCorpse(deltaTime: number) {
const intersecting = this.container.findIntersecting(
getBoundingBoxOfCircle(
new Circle(
this.center,
CharacterPhysical.boundRadius + settings.maxGravityDistance,
),
),
);
let grounded = false;
for (const part of [this.leftFoot, this.rightFoot, this.head]) {
part.applyForce(forceAtPosition(part.center, intersecting), deltaTime);
vec2.add(part.velocity, part.velocity, this.bodyVelocity);
const { hitObject } = part.stepManually(deltaTime);
if (hitObject instanceof PlanetPhysical) {
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.
decayMomentum(this.bodyVelocity, grounded, deltaTime);
}
private regenerateHealth(deltaTimeInSeconds: number) {
this.timeSinceLastDamage += deltaTimeInSeconds;
if (
this.timeSinceLastDamage > settings.playerOutOfCombatDelaySeconds &&
@ -548,17 +389,35 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
);
this.syncHealth();
}
this.groundPlanet?.registerPresence(this);
stepCharacterMovement(
this,
this.movementWorld,
this.movementDirection,
deltaTimeInSeconds,
);
}
public onDie() {
this.isDestroyed = true;
this.remoteCall('onDie');
private animateScaling(q: number) {
this.head.radius = headRadius * q;
this.leftFoot.radius = this.rightFoot.radius = feetRadius * q;
}
private destroy() {
this.container.removeObject(this);
this.container.removeObject(this.head);
this.container.removeObject(this.leftFoot);
this.container.removeObject(this.rightFoot);
private freeFallCorpse(deltaTime: number) {
const planets = this.movementWorld.groundsNear(
this.center,
boundRadius + settings.maxGravityDistance,
);
let grounded = false;
for (const part of this.parts) {
vec2.copy(part.velocity, this.bodyVelocity);
applyForce(part, sumGravity(planets, part.center), deltaTime);
if (part.stepManually(deltaTime) instanceof PlanetPhysical) {
grounded = true;
}
}
decayMomentum(this.bodyVelocity, grounded, deltaTime);
}
}

View file

@ -1,143 +1,64 @@
import { vec2 } from 'gl-matrix';
import {
Circle,
CommandExecutors,
CommandReceiver,
GameObject,
resolveCircleMovement,
serializesTo,
} from 'shared';
import { BoundingBox } from '../physics/bounding-boxes/bounding-box';
import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base';
import { PhysicalContainer } from '../physics/containers/physical-container';
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
import { Physical } from '../physics/physicals/physical';
import { ReactToCollisionCommand } from '../commands/react-to-collision';
@serializesTo(Circle)
export class CirclePhysical extends CommandReceiver implements Circle, DynamicPhysical {
readonly canCollide = true;
readonly canMove = true;
import { Circle, GameObject, resolveCircleMovement } from 'shared';
import { BoundingBox } from '../physics/bounding-box';
import { PhysicalContainer } from '../physics/physical-container';
import { Physical } from '../physics/physical';
export class CirclePhysical extends Circle implements Physical {
public readonly canCollide = true;
public velocity = vec2.create();
public lastNormal = vec2.fromValues(0, 1);
private _boundingBox: BoundingBox;
protected commandExecutors: CommandExecutors = {
[ReactToCollisionCommand.type]: this.onCollision.bind(this),
};
private readonly box = new BoundingBox();
constructor(
private _center: vec2,
private _radius: number,
public owner: GameObject,
center: vec2,
radius: number,
public readonly owner: Physical,
private readonly container: PhysicalContainer,
// Public + readonly so a CirclePhysical structurally satisfies the shared
// PhysicsBody interface the movement simulation operates on.
public readonly restitution = 0,
) {
super();
this._boundingBox = new BoundingBox();
this.recalculateBoundingBox();
super(center, radius);
}
public get boundingBox(): BoundingBoxBase {
return this._boundingBox;
}
public get center(): vec2 {
return this._center;
}
public set center(value: vec2) {
this._center = value;
this.recalculateBoundingBox();
}
public onCollision(c: ReactToCollisionCommand) {
this.owner.handleCommand(c);
public get boundingBox(): BoundingBox {
return this.box.setCircle(this.center, this.radius);
}
public get gameObject(): GameObject {
return this.owner;
return this.owner.gameObject;
}
public get radius(): number {
return this._radius;
public onCollision(other: GameObject) {
this.owner.onCollision?.(other);
}
public set radius(value: number) {
this._radius = value;
this.recalculateBoundingBox();
}
public distance(target: vec2): number {
return vec2.distance(target, this.center) - this.radius;
}
private recalculateBoundingBox() {
this._boundingBox.xMin = this.center.x - this._radius;
this._boundingBox.xMax = this.center.x + this._radius;
this._boundingBox.yMin = this.center.y - this._radius;
this._boundingBox.yMax = this.center.y + this._radius;
}
public applyForce(force: vec2, timeInSeconds: number) {
vec2.add(
this.velocity,
this.velocity,
vec2.scale(vec2.create(), 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.
public stepManually(
deltaTimeInSeconds: number,
possibleIntersectors?: Array<Physical>,
): {
hitObject: GameObject | undefined;
velocity: vec2;
} {
): GameObject | undefined {
const intersecting = (
possibleIntersectors ?? this.sweptBroadphase(deltaTimeInSeconds)
).filter((b) => b.gameObject !== this.gameObject && b.canCollide);
const { hitObject, velocity } = resolveCircleMovement(
const hit = resolveCircleMovement(
this,
deltaTimeInSeconds,
intersecting,
(intersected) => {
const physical = intersected as Physical;
physical.handleCommand(new ReactToCollisionCommand(this.gameObject));
this.handleCommand(new ReactToCollisionCommand(physical.gameObject));
physical.onCollision?.(this.gameObject);
this.onCollision(physical.gameObject);
},
);
) as Physical | undefined;
return { hitObject: (hitObject as Physical | undefined)?.gameObject, velocity };
return hit?.gameObject;
}
// 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),
const sweep = vec2.length(this.velocity) * deltaTimeInSeconds;
return this.container.findIntersecting(
BoundingBox.ofCircle(this.center, this.radius + sweep),
);
this.radius += sweep;
const intersecting = this.container.findIntersecting(this.boundingBox);
this.radius -= sweep;
return intersecting;
}
public toArray(): Array<any> {
const { center, radius } = this;
return [center, radius];
}
}

View file

@ -1,31 +1,15 @@
import { vec2, vec3 } from 'gl-matrix';
import { LampBase, settings, id, serializesTo } from 'shared';
import { LampBase, settings, id } from 'shared';
import { BoundingBox } from '../physics/bounding-box';
import { Physical } from '../physics/physical';
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
import { StaticPhysical } from '../physics/physicals/static-physical';
@serializesTo(LampBase)
export class LampPhysical extends LampBase implements StaticPhysical {
export class LampPhysical extends LampBase implements Physical {
public readonly canCollide = false;
public readonly canMove = false;
public readonly boundingBox: BoundingBox;
constructor(center: vec2, color: vec3, lightness: number) {
super(id(), center, color, lightness);
}
private _boundingBox?: ImmutableBoundingBox;
public get boundingBox(): ImmutableBoundingBox {
if (!this._boundingBox) {
this._boundingBox = new ImmutableBoundingBox(
this.center.x - settings.lightCutoffDistance,
this.center.x + settings.lightCutoffDistance,
this.center.y - settings.lightCutoffDistance,
this.center.y + settings.lightCutoffDistance,
);
}
return this._boundingBox;
this.boundingBox = BoundingBox.ofCircle(center, settings.lightCutoffDistance);
}
public get gameObject(): this {
@ -33,6 +17,8 @@ export class LampPhysical extends LampBase implements StaticPhysical {
}
public queueSetLight(color: vec3, lightness: number) {
this.color = vec3.clone(color);
this.lightness = lightness;
this.remoteCall('setLight', color, lightness);
}

View file

@ -1,195 +1,103 @@
import { vec2 } from 'gl-matrix';
import {
clamp,
clamp01,
id,
mix,
Random,
serializesTo,
settings,
PlanetBase,
CharacterTeam,
PropertyUpdatesForObject,
UpdatePropertyCommand,
CommandExecutors,
CommandReceiver,
} from 'shared';
import { GeneratePointsCommand } from '../commands/generate-points';
import { AnnounceCommand } from '../commands/announce';
import { StepCommand } from '../commands/step';
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
import { StaticPhysical } from '../physics/physicals/static-physical';
import { addPointsForTeam, GameEvents } from '../game-events';
import { BoundingBox } from '../physics/bounding-box';
import { Physical } from '../physics/physical';
import { PhysicalContainer } from '../physics/physical-container';
import { LampPhysical } from './lamp-physical';
import type { CharacterPhysical } from './character-physical';
@serializesTo(PlanetBase)
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.
public readonly isGround = true;
export const planetsIn = (objects: Array<Physical>): Array<PlanetPhysical> =>
objects.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical);
export const planetsNear = (
container: PhysicalContainer,
center: vec2,
radius: number,
): Array<PlanetPhysical> =>
planetsIn(container.findIntersecting(BoundingBox.ofCircle(center, radius)));
export class PlanetPhysical extends PlanetBase implements Physical {
public readonly boundingBox: BoundingBox;
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.
private rotation = 0;
private readonly rotationSpeed: number;
private cachedRotation = Number.NaN;
private cosRotation = 1;
private sinRotation = 0;
private _boundingBox?: ImmutableBoundingBox;
private readonly lamps: Array<LampPhysical> = [];
private lastTeam: CharacterTeam = CharacterTeam.neutral;
// 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.
private presentCharacters: Array<CharacterPhysical> = [];
private isContested = false;
private timeSinceLastPointGeneration = 0;
protected commandExecutors: CommandExecutors = {
[StepCommand.type]: this.step.bind(this),
};
public addLamp(lamp: LampPhysical) {
this.lamps.push(lamp);
}
constructor(vertices: Array<vec2>, isKeystone = false) {
constructor(
vertices: Array<vec2>,
isKeystone: boolean,
private readonly game: GameEvents,
) {
super(id(), vertices, 0.5, isKeystone);
const sizeClass = clamp01(
(this.radius - settings.planetMinReferenceRadius) /
(settings.planetMaxReferenceRadius - settings.planetMinReferenceRadius),
);
this.sizePointMultiplier = mix(1, settings.planetSizePointMultiplierMax, sizeClass);
this.rotationSpeed =
this.angularVelocity =
(0.05 + Random.getRandom() * 0.07) * (Random.getRandom() < 0.5 ? -1 : 1);
this.boundingBox = BoundingBox.ofCircle(
this.center,
Math.max(...vertices.map((v) => vec2.distance(this.center, v))),
);
}
public get gameObject(): this {
return this;
}
public addLamp(lamp: LampPhysical) {
this.lamps.push(lamp);
}
// 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 {
// Evaluate the SDF in the planet's own rotating frame so this collision
// outline turns in lockstep with the rendered planet (see planet-shape.ts).
const local = this.toLocalFrame(target);
const startEnd = this.vertices[0];
let vb = startEnd;
let d = vec2.dist(local, vb);
let sign = 1;
for (let i = 1; i <= this.vertices.length; i++) {
const va = vb;
vb = i === this.vertices.length ? startEnd : this.vertices[i];
const targetFromDelta = vec2.subtract(vec2.create(), local, va);
const toFromDelta = vec2.subtract(vec2.create(), vb, va);
const h = clamp01(
vec2.dot(targetFromDelta, toFromDelta) / vec2.squaredLength(toFromDelta),
);
const ds = vec2.fromValues(
vec2.dist(targetFromDelta, vec2.scale(vec2.create(), toFromDelta, h)),
toFromDelta.x * targetFromDelta.y - toFromDelta.y * targetFromDelta.x,
);
if (
(local.y >= va.y && local.y < vb.y && ds.y > 0) ||
(local.y < va.y && local.y >= vb.y && ds.y <= 0)
) {
sign *= -1;
}
d = Math.min(d, ds.x);
}
return sign * d;
}
// Rotate a world point by -rotation about the centre, matching the shader's
// `localTarget = center + R(rotation) * (target - center)` transform exactly.
private toLocalFrame(target: vec2): vec2 {
if (this.rotation !== this.cachedRotation) {
this.cachedRotation = this.rotation;
this.cosRotation = Math.cos(this.rotation);
this.sinRotation = Math.sin(this.rotation);
}
const dx = target.x - this.center.x;
const dy = target.y - this.center.y;
return vec2.fromValues(
this.center.x + this.cosRotation * dx - this.sinRotation * dy,
this.center.y + this.sinRotation * dx + this.cosRotation * dy,
);
}
// Signed angular velocity in rad/s, exposed so a character standing on the
// planet can ride its spin (see carryWithRotatingPlanet in shared).
public get angularVelocity(): number {
return this.rotationSpeed;
}
public get team(): CharacterTeam {
return Math.abs(this.ownership - 0.5) < settings.planetControlThreshold
? CharacterTeam.neutral
: this.ownership < 0.5
? CharacterTeam.blue
: CharacterTeam.red;
}
private timeSinceLastPointGeneration = 0;
private getPoints(game: CommandReceiver) {
if (this.timeSinceLastPointGeneration > settings.planetPointGenerationInterval) {
this.timeSinceLastPointGeneration = 0;
const value = Math.round(
settings.planetPointGenerationValue * this.sizePointMultiplier,
);
game.handleCommand(
new GeneratePointsCommand(
this.team === CharacterTeam.blue ? value : 0,
this.team === CharacterTeam.red ? value : 0,
),
);
}
}
private step({ deltaTimeInSeconds, game }: StepCommand) {
this.rotation += deltaTimeInSeconds * this.rotationSpeed;
public step(deltaTimeInSeconds: number) {
this.advanceRotation(deltaTimeInSeconds);
this.timeSinceLastPointGeneration += deltaTimeInSeconds;
// In reverse order, so that teams can achieve a 100% control.
this.getPoints(game);
this.generatePoints();
this.resolveCapture(deltaTimeInSeconds);
this.detectFlip(game);
this.detectFlip();
this.presentCharacters = [];
this.presentCharacters.length = 0;
}
private generatePoints() {
if (this.timeSinceLastPointGeneration > settings.planetPointGenerationInterval) {
this.timeSinceLastPointGeneration = 0;
this.awardToOwner(settings.planetPointGenerationValue);
}
}
private awardToOwner(base: number) {
addPointsForTeam(this.game, this.team, Math.round(base * this.sizePointMultiplier));
}
// 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;
for (const c of this.presentCharacters) {
if (!c.isAlive) {
continue;
}
if (c.team === CharacterTeam.blue) {
blue++;
} else if (c.team === CharacterTeam.red) {
@ -206,14 +114,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) {
@ -222,7 +127,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
}
}
private detectFlip(game: CommandReceiver) {
private detectFlip() {
const currentTeam = this.team;
if (currentTeam === this.lastTeam) {
return;
@ -230,22 +135,15 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
this.lastTeam = currentTeam;
if (currentTeam !== CharacterTeam.neutral) {
const reward = Math.round(
settings.captureFlipPointReward * this.sizePointMultiplier,
);
this.remoteCall('generatedPoints', reward);
game.handleCommand(
new GeneratePointsCommand(
currentTeam === CharacterTeam.blue ? reward : 0,
currentTeam === CharacterTeam.red ? reward : 0,
),
this.remoteCall(
'generatedPoints',
Math.round(settings.captureFlipPointReward * this.sizePointMultiplier),
);
this.awardToOwner(settings.captureFlipPointReward);
if (this.isKeystone) {
game.handleCommand(
new AnnounceCommand(
`Team <span class="${currentTeam}">${currentTeam}</span> captured the Heart`,
),
this.game.announce(
`Team <span class="${currentTeam}">${currentTeam}</span> captured the Heart`,
);
}
}
@ -253,22 +151,12 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
const control = Math.abs(this.ownership - 0.5) / 0.5;
const lightness = mix(settings.lampMinLightness, settings.lampMaxLightness, control);
const color = settings.palette[settings.colorIndices[currentTeam]];
this.lamps.forEach((lamp) => lamp.queueSetLight(color, lightness));
this.remoteCall('onFlipped', currentTeam);
}
public getPropertyUpdates(): PropertyUpdatesForObject {
return new PropertyUpdatesForObject(this.id, [
new UpdatePropertyCommand('ownership', this.ownership, 0),
// Stream the spin rate as the rate-of-change so the client can keep the
// angle moving when snapshots run late (see planet-view.ts).
new UpdatePropertyCommand('rotation', this.rotation, this.rotationSpeed),
]);
}
public takeControl(team: CharacterTeam, deltaTime: number) {
private takeControl(team: CharacterTeam, deltaTime: number) {
if (team === CharacterTeam.blue) {
this.ownership -= (0.5 / settings.takeControlTimeInSeconds) * deltaTime;
} else if (team === CharacterTeam.red) {
@ -279,10 +167,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
-Math.sign(this.ownership - 0.5) *
(0.5 / settings.loseControlTimeInSeconds) *
deltaTime;
if (
(previous < 0.5 && this.ownership > 0.5) ||
(previous > 0.5 && this.ownership < 0.5)
) {
if ((previous - 0.5) * (this.ownership - 0.5) < 0) {
this.ownership = 0.5;
}
}
@ -290,45 +175,14 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
this.ownership = clamp01(this.ownership);
}
public get boundingBox(): ImmutableBoundingBox {
if (!this._boundingBox) {
// The polygon spins about its centre (see distance), so this static box
// has to cover every orientation, not just the spawn-time one: take the
// circumscribed circle around the rotation centre.
const maxVertexDistance = this.vertices.reduce(
(max, vertex) => Math.max(max, vec2.distance(this.center, vertex)),
0,
);
this._boundingBox = new ImmutableBoundingBox(
this.center.x - maxVertexDistance,
this.center.x + maxVertexDistance,
this.center.y - maxVertexDistance,
this.center.y + maxVertexDistance,
);
}
return this._boundingBox;
}
public getForce(position: vec2): vec2 {
const diff = vec2.subtract(vec2.create(), this.center, position);
const dist = Math.max(settings.minGravityDistance, vec2.length(diff) - this.radius);
vec2.normalize(diff, diff);
const scale = clamp(
settings.maxGravityQ * ((settings.maxGravityDistance / dist) ** 1.5 - 1),
0,
settings.maxGravityStrength,
);
return vec2.scale(diff, diff, scale);
}
// GroundSurface alias the shared movement simulation calls for gravity.
public gravityAt(position: vec2): vec2 {
return this.getForce(position);
}
public get gameObject(): this {
return this;
public getPropertyUpdates(timeScale = 1): PropertyUpdatesForObject {
return new PropertyUpdatesForObject(this.id, [
new UpdatePropertyCommand('ownership', this.ownership, 0),
new UpdatePropertyCommand(
'rotation',
this.rotation,
this.angularVelocity * timeScale,
),
]);
}
}

View file

@ -2,57 +2,42 @@ import { vec2 } from 'gl-matrix';
import {
id,
settings,
serializesTo,
ProjectileBase,
CharacterTeam,
PropertyUpdatesForObject,
UpdatePropertyCommand,
CommandExecutors,
Circle,
marchCircle,
GameObject,
evaluateSdf,
sumGravity,
} from 'shared';
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
import { BoundingBox } from '../physics/bounding-box';
import { CirclePhysical } from './circle-physical';
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
import { PhysicalContainer } from '../physics/containers/physical-container';
import { Physical } from '../physics/physical';
import { PhysicalContainer } from '../physics/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 { planetsIn } from './planet-physical';
@serializesTo(ProjectileBase)
export class ProjectilePhysical extends ProjectileBase implements DynamicPhysical {
export class ProjectilePhysical extends ProjectileBase implements Physical {
public readonly canCollide = true;
public readonly canMove = true;
public readonly object: CirclePhysical;
private isDestroyed = false;
private bounceCount = 0;
private _boundingBox?: ImmutableBoundingBox;
public object: CirclePhysical;
protected commandExecutors: CommandExecutors = {
[StepCommand.type]: this.handleStep.bind(this),
[ReactToCollisionCommand.type]: this.onCollision.bind(this),
};
constructor(
center: vec2,
radius: number,
public strength: number,
team: CharacterTeam,
private velocity: vec2,
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.
private readonly container: PhysicalContainer,
public readonly charge: number = 0,
) {
super(id(), center, radius, team, strength);
this.object = new CirclePhysical(center, radius, this, container, 0.9);
this.moveOutsideOfObject();
this.object = new CirclePhysical(this.center, radius, this, container, 0.9);
vec2.copy(this.object.velocity, velocity);
this.moveOutsideFriendlyBodies();
}
public get isAlive(): boolean {
@ -60,36 +45,14 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
}
public get direction(): vec2 {
const direction = vec2.clone(this.velocity);
const direction = vec2.clone(this.object.velocity);
return vec2.length(direction) > 0
? vec2.normalize(direction, direction)
: vec2.fromValues(0, -1);
}
private moveOutsideOfObject() {
let wasCollision = true;
const delta = vec2.scale(
vec2.create(),
vec2.normalize(vec2.create(), this.velocity),
10,
);
while (wasCollision) {
const intersecting = this.container
.findIntersecting(this.boundingBox)
.filter((g) => g instanceof CharacterPhysical && g.team === this.team);
const { hitSurface } = marchCircle(this.object, delta, intersecting, true);
wasCollision = hitSurface;
}
vec2.add(this.center, this.center, delta);
vec2.add(this.center, this.center, delta);
}
public get boundingBox(): ImmutableBoundingBox {
if (!this._boundingBox) {
this._boundingBox = (this.object as CirclePhysical).boundingBox;
}
return this._boundingBox;
public get boundingBox(): BoundingBox {
return this.object.boundingBox;
}
public get gameObject(): this {
@ -100,6 +63,19 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
return this.object.distance(target);
}
private moveOutsideFriendlyBodies() {
const step = vec2.scale(vec2.create(), this.direction, 10);
for (let pass = 0; pass < 32; pass++) {
vec2.add(this.center, this.center, step);
const friendly = this.container
.findIntersecting(this.boundingBox)
.filter((o) => o instanceof CharacterPhysical && o.team === this.team);
if (evaluateSdf(this.center, friendly) >= this.radius) {
return;
}
}
}
public destroy() {
if (!this.isDestroyed) {
this.isDestroyed = true;
@ -107,22 +83,35 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
}
}
public onCollision({ other }: ReactToCollisionCommand) {
if (
!(other instanceof CharacterPhysical && other.team === this.team) &&
this.bounceCount++ === settings.projectileMaxBounceCount
) {
public onCollision(other: GameObject) {
if (other instanceof CharacterPhysical && other.team === this.team) {
return;
}
this.bounceCount++;
if (this.bounceCount > settings.projectileMaxBounceCount) {
this.destroy();
}
}
public getPropertyUpdates(): PropertyUpdatesForObject {
public getPropertyUpdates(timeScale = 1): PropertyUpdatesForObject {
return new PropertyUpdatesForObject(this.id, [
new UpdatePropertyCommand('center', this.center, this.velocity),
new UpdatePropertyCommand(
'center',
this.center,
vec2.scale(vec2.create(), this.object.velocity, timeScale),
),
new UpdatePropertyCommand(
'strength',
this.strength,
-settings.projectileFadeSpeed * timeScale,
),
]);
}
private handleStep({ deltaTimeInSeconds }: StepCommand) {
public step(deltaTimeInSeconds: number) {
if (!this.isAlive) {
return;
}
super.step(deltaTimeInSeconds);
if (this.strength <= 0) {
@ -130,31 +119,17 @@ 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.
const intersecting = settings.projectileGravityEnabled
? this.container.findIntersecting(
getBoundingBoxOfCircle(
new Circle(this.center, this.object.radius + settings.maxGravityDistance),
),
)
: undefined;
const intersecting = this.container.findIntersecting(
BoundingBox.ofCircle(this.center, this.radius + settings.maxGravityDistance),
);
if (intersecting) {
vec2.scaleAndAdd(
this.velocity,
this.velocity,
forceAtPosition(this.center, intersecting),
settings.projectileGravityScale * deltaTimeInSeconds,
);
}
vec2.scaleAndAdd(
this.object.velocity,
this.object.velocity,
sumGravity(planetsIn(intersecting), this.center),
settings.projectileGravityScale * deltaTimeInSeconds,
);
vec2.copy(this.object.velocity, this.velocity);
const { velocity } = this.object.stepManually(deltaTimeInSeconds, intersecting);
vec2.copy(this.velocity, velocity);
this.object.stepManually(deltaTimeInSeconds, intersecting);
}
}

View file

@ -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() * 0x100000000) >>> 0,
scoreLimit: 2500,
};

View file

@ -0,0 +1,36 @@
import { vec2 } from 'gl-matrix';
export class BoundingBox {
constructor(
public xMin = 0,
public xMax = 0,
public yMin = 0,
public yMax = 0,
) {}
public static ofCircle(center: vec2, radius: number): BoundingBox {
return new BoundingBox(
center[0] - radius,
center[0] + radius,
center[1] - radius,
center[1] + radius,
);
}
public setCircle(center: vec2, radius: number): BoundingBox {
this.xMin = center[0] - radius;
this.xMax = center[0] + radius;
this.yMin = center[1] - radius;
this.yMax = center[1] + radius;
return this;
}
public intersects(other: BoundingBox): boolean {
return (
this.xMin < other.xMax &&
this.xMax > other.xMin &&
this.yMin < other.yMax &&
this.yMax > other.yMin
);
}
}

View file

@ -1,62 +0,0 @@
import { vec2 } from 'gl-matrix';
// axis-aligned
export abstract class BoundingBoxBase {
constructor(
protected _xMin: number = 0,
protected _xMax: number = 0,
protected _yMin: number = 0,
protected _yMax: number = 0,
) {}
[key: number]: number | undefined;
public get 0(): number {
return this._xMin;
}
public get 1(): number {
return this._xMax;
}
public get 2(): number {
return this._yMin;
}
public get 3(): number {
return this._yMax;
}
public get xMin(): number {
return this._xMin;
}
public get xMax(): number {
return this._xMax;
}
public get yMin(): number {
return this._yMin;
}
public get yMax(): number {
return this._yMax;
}
public get topLeft(): vec2 {
return vec2.fromValues(this._xMin, this._yMax);
}
public get size(): vec2 {
return vec2.fromValues(this._xMax - this._xMin, this._yMax - this._yMin);
}
public intersects(other: BoundingBoxBase): boolean {
return (
this._xMin < other._xMax &&
this._xMax > other._xMin &&
this._yMin < other._yMax &&
this._yMax > other._yMin
);
}
}

Some files were not shown because too many files have changed in this diff Show more