even more

This commit is contained in:
Andras Schmelczer 2026-08-23 10:27:55 +01:00
commit 8f50ebdb97
96 changed files with 1842 additions and 3095 deletions

View file

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

View file

@ -0,0 +1,4 @@
export interface GameEvents {
addPoints(blue: number, red: number): void;
announce(text: string): void;
}

View file

@ -1,4 +1,3 @@
import { PhysicalContainer } from './physics/containers/physical-container';
import { Server, Socket } from 'socket.io'; import { Server, Socket } from 'socket.io';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks'; import { performance } from 'node:perf_hooks';
@ -13,39 +12,33 @@ import {
GameEndCommand, GameEndCommand,
GameStartCommand, GameStartCommand,
Command, Command,
CommandReceiver,
CommandExecutors,
ServerAnnouncement, ServerAnnouncement,
JoinRejectionReason, JoinRejectionReason,
beginPropertyUpdateGeneration, GameObject,
PropertyUpdatesForObject,
} from 'shared'; } from 'shared';
import { PhysicalContainer } from './physics/physical-container';
import { createWorld } from './create-world'; import { createWorld } from './create-world';
import { Options } from './options'; import { Options } from './options';
import { GameEvents } from './game-events';
import { CarriedScore, PlayerContainer } from './players/player-container'; import { CarriedScore, PlayerContainer } from './players/player-container';
import { ServerFullError } from './players/server-full-error';
import { Player } from './players/player'; import { Player } from './players/player';
import { StepCommand, GeneratePointsCommand, AnnounceCommand } from './commands/commands';
const gameStateSubscribedRoom = 'gameStateSubscribedRoom'; const gameStateSubscribedRoom = 'gameStateSubscribedRoom';
const endTitleSeconds = 6;
// The payload is whatever the client sent: `socket.emit('PlayerJoining')` with
// no argument arrives as undefined. socket.io dispatches listeners inside a
// `process.nextTick` with no try/catch of its own, so a dereference of an
// unchecked payload here would surface as an uncaught exception and take the
// whole match down with the process.
const asPlayerInformation = (payload: unknown): PlayerInformation | undefined => { const asPlayerInformation = (payload: unknown): PlayerInformation | undefined => {
if (!payload || typeof payload !== 'object') { if (!payload || typeof payload !== 'object') {
return undefined; return undefined;
} }
const { name, reconnectToken } = payload as Record<string, unknown>; const { name, reconnectToken } = payload as Record<string, unknown>;
return { return {
// Coerced and length-capped downstream, in PlayerBase.createCharacter.
name: name as string, name: name as string,
reconnectToken: typeof reconnectToken === 'string' ? reconnectToken : undefined, reconnectToken: typeof reconnectToken === 'string' ? reconnectToken : undefined,
}; };
}; };
export class GameServer extends CommandReceiver { export class GameServer implements GameEvents {
private objects!: PhysicalContainer; private objects!: PhysicalContainer;
private players!: PlayerContainer; private players!: PlayerContainer;
private lastPhysicsBase = process.hrtime.bigint(); private lastPhysicsBase = process.hrtime.bigint();
@ -57,45 +50,24 @@ export class GameServer extends CommandReceiver {
private isInEndGame = false; private isInEndGame = false;
private timeScaling = 1; private timeScaling = 1;
private statReportMs = Date.now(); private statReportMs = Date.now();
private timeSinceLastServerStateUpdate = 0;
private timeSinceLastPointUpdate = 0;
private physicsAccumulator = 0;
private saturatedFrames = 0;
private droppedInboundMessages = 0;
private initialize() { private readonly joinedSockets = new Map<
const previousPlayers = this.players; Socket,
this.releaseJoinedSockets(); { player: Player; release: () => void }
this.objects = new PhysicalContainer(); >();
createWorld(this.objects); private inboundBudget = new WeakMap<Socket, { tokens: number; lastMs: number }>();
this.objects.initialize();
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;
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)),
};
constructor( constructor(
private readonly io: Server, private readonly io: Server,
private options: Options, private options: Options,
) { ) {
super();
this.initialize(); this.initialize();
// Both listeners are metered: they exist before any join, so the limiter
// inside onPlayerToServer cannot see them, and each one costs a reply or an
// allocation an unauthenticated client could otherwise ask for at line rate.
io.on('connection', (socket: Socket) => { io.on('connection', (socket: Socket) => {
socket.on(TransportEvents.PlayerJoining, (payload: unknown) => { socket.on(TransportEvents.PlayerJoining, (payload: unknown) => {
if (!this.allowInboundMessage(socket)) { if (!this.allowInboundMessage(socket)) {
@ -111,18 +83,35 @@ export class GameServer extends CommandReceiver {
}); });
socket.on(TransportEvents.SubscribeForServerInfoUpdates, () => { socket.on(TransportEvents.SubscribeForServerInfoUpdates, () => {
if (!this.allowInboundMessage(socket)) { if (this.allowInboundMessage(socket)) {
return; socket.join(gameStateSubscribedRoom);
} }
socket.join(gameStateSubscribedRoom);
}); });
}); });
} }
private readonly joinedSockets = new Map< private initialize() {
Socket, const previousPlayers = this.players;
{ player: Player; release: () => void } 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;
previousPlayers?.queueCommandForEachClient(new GameStartCommand());
previousPlayers?.sendQueuedCommands();
}
private handleJoin(socket: Socket, payload: unknown) { private handleJoin(socket: Socket, payload: unknown) {
const playerInfo = asPlayerInformation(payload); const playerInfo = asPlayerInformation(payload);
@ -141,9 +130,6 @@ export class GameServer extends CommandReceiver {
return; return;
} }
// A reconnect arrives seconds after the drop, long before engine.io times
// the dead socket out. Retire that ghost first so its slot, team and score
// go back to the returning client.
const carried = this.retireGhost(playerInfo.reconnectToken); const carried = this.retireGhost(playerInfo.reconnectToken);
if (this.players.isFull) { if (this.players.isFull) {
@ -151,20 +137,7 @@ export class GameServer extends CommandReceiver {
return; return;
} }
let player: Player; const player = this.players.createPlayer(playerInfo, socket, carried);
try {
player = this.players.createPlayer(playerInfo, socket, carried);
} catch (e) {
console.error('Failed to register joining player', e);
socket.emit(
TransportEvents.JoinRejected,
e instanceof ServerFullError
? JoinRejectionReason.ServerFull
: JoinRejectionReason.InvalidRequest,
);
socket.disconnect();
return;
}
const onPlayerToServer = (json: string) => { const onPlayerToServer = (json: string) => {
try { try {
@ -203,14 +176,8 @@ export class GameServer extends CommandReceiver {
this.sendServerStateUpdate(); this.sendServerStateUpdate();
} }
// A WeakMap, not a Map: sockets that never join are metered too, and they
// have no join-scoped teardown to remove their entry.
private inboundBudget = new WeakMap<Socket, { tokens: number; lastMs: number }>();
private droppedInboundMessages = 0;
private allowInboundMessage(socket: Socket): boolean { private allowInboundMessage(socket: Socket): boolean {
// performance.now(), never Date.now(): a backwards wall-clock step (an NTP // performance.now(), not Date.now(): a backwards wall-clock step would lock the client out.
// correction, a VM resync) would make the refill negative and lock the
// client out of sending input for as long as the step was.
const nowMs = performance.now(); const nowMs = performance.now();
const burst = settings.maxInboundMessageBurst; const burst = settings.maxInboundMessageBurst;
const perSecond = settings.maxInboundMessagesPerSecond; const perSecond = settings.maxInboundMessagesPerSecond;
@ -238,7 +205,7 @@ export class GameServer extends CommandReceiver {
} }
for (const [socket, { player }] of this.joinedSockets) { for (const [socket, { player }] of this.joinedSockets) {
if (player.reconnectToken === token) { if (player.reconnectToken === token) {
const carried = { team: player.team, ...player.scoreSnapshot }; const carried = { team: player.team, ...player.score };
this.removeJoined(socket); this.removeJoined(socket);
return carried; return carried;
} }
@ -253,26 +220,17 @@ export class GameServer extends CommandReceiver {
} }
this.joinedSockets.delete(socket); this.joinedSockets.delete(socket);
this.inboundBudget.delete(socket); this.inboundBudget.delete(socket);
// Only the listeners this join added: socket.io keeps its own internal
// 'error' guard and the connection-scoped PlayerJoining handler here.
joined.release(); joined.release();
joined.player.destroy(); joined.player.destroy();
this.players.deletePlayer(joined.player); this.players.deletePlayer(joined.player);
} }
private releaseJoinedSockets() {
this.joinedSockets.forEach(({ release }) => release());
this.joinedSockets.clear();
this.inboundBudget = new WeakMap();
}
private timeSinceLastServerStateUpdate = 0;
public sendServerStateUpdate() { public sendServerStateUpdate() {
this.io this.io
.to(gameStateSubscribedRoom) .to(gameStateSubscribedRoom)
.emit(TransportEvents.ServerInfoUpdate, [ .emit(TransportEvents.ServerInfoUpdate, [
this.players.count, this.players.count,
(Math.max(this.bluePoints, this.redPoints) / this.options.scoreLimit) * 100, this.serverInfo.gameStatePercent,
]); ]);
} }
@ -280,7 +238,7 @@ export class GameServer extends CommandReceiver {
this.handlePhysics(); this.handlePhysics();
} }
private addPoints({ blue, red }: GeneratePointsCommand) { public addPoints(blue: number, red: number) {
if (this.isInEndGame) { if (this.isInEndGame) {
return; return;
} }
@ -297,31 +255,49 @@ export class GameServer extends CommandReceiver {
} }
} }
public announce(text: string) {
this.players.queueCommandForEachClient(new ServerAnnouncement(text));
}
private announceMatchPointOnce(team: CharacterTeam, points: number) { private announceMatchPointOnce(team: CharacterTeam, points: number) {
if ( if (
!this.matchPointAnnounced[team] && !this.matchPointAnnounced[team] &&
points >= this.options.scoreLimit * settings.matchPointScoreRatio points >= this.options.scoreLimit * settings.matchPointScoreRatio
) { ) {
this.matchPointAnnounced[team] = true; this.matchPointAnnounced[team] = true;
this.players.queueCommandForEachClient( this.announce(`Match point — team <span class="${team}">${team}</span>!`);
new ServerAnnouncement(
`Match point — team <span class="${team}">${team}</span>!`,
),
);
} }
} }
private endGame(winningTeam: CharacterTeam) { private endGame(winningTeam: CharacterTeam) {
this.isInEndGame = true; this.isInEndGame = true;
const endTitleLength = 6;
this.players.endGame(winningTeam); this.players.endGame(winningTeam);
this.players.queueCommandForEachClient(new GameEndCommand()); this.players.queueCommandForEachClient(new GameEndCommand());
setTimeout(() => this.initialize(), endTitleLength * 1000 * 1.1); setTimeout(() => this.initialize(), endTitleSeconds * 1000 * 1.1);
} }
private timeSinceLastPointUpdate = 0; private reportStats() {
private physicsAccumulator = 0; const mem = `${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`;
private saturatedFrames = 0; console.info(`Memory: ${mem}, Players: ${this.players.count}`);
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() { private handlePhysics() {
const frameStart = process.hrtime.bigint(); const frameStart = process.hrtime.bigint();
@ -330,26 +306,7 @@ export class GameServer extends CommandReceiver {
if (Date.now() - this.statReportMs > 30000) { if (Date.now() - this.statReportMs > 30000) {
this.statReportMs = Date.now(); this.statReportMs = Date.now();
const mem = `${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`; this.reportStats();
console.info(`Memory: ${mem}, Players: ${this.players.count}`);
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;
}
} }
if ((this.timeSinceLastServerStateUpdate += delta) > 4) { if ((this.timeSinceLastServerStateUpdate += delta) > 4) {
@ -387,17 +344,21 @@ export class GameServer extends CommandReceiver {
this.timeScaling *= Math.pow(settings.endGameDeltaScaling, fixedDelta); this.timeScaling *= Math.pow(settings.endGameDeltaScaling, fixedDelta);
scaledDelta /= this.timeScaling; scaledDelta /= this.timeScaling;
} }
this.objects.handleCommand(new StepCommand(scaledDelta, this)); this.objects.step(scaledDelta);
this.players.step(scaledDelta); this.players.step(scaledDelta);
} }
beginPropertyUpdateGeneration(); const propertyUpdates = new Map<GameObject, PropertyUpdatesForObject | undefined>();
this.players.stepCommunication(delta); this.players.stepCommunication(delta, (object) => {
if (!propertyUpdates.has(object)) {
propertyUpdates.set(object, object.getPropertyUpdates());
}
return propertyUpdates.get(object);
});
this.objects.resetRemoteCalls(); this.objects.resetRemoteCalls();
const elapsed = Number(process.hrtime.bigint() - frameStart) / 1e9; const elapsed = Number(process.hrtime.bigint() - frameStart) / 1e9;
setTimeout(() => this.handlePhysics(), Math.max(0, fixedDelta - elapsed) * 1000);
setTimeout(this.handlePhysics.bind(this), Math.max(0, fixedDelta - elapsed) * 1000);
} }
public get serverInfo(): ServerInformation { public get serverInfo(): ServerInformation {

View file

@ -2,7 +2,7 @@ import { Server as IoServer } from 'socket.io';
import express from 'express'; import express from 'express';
import { Server } from 'http'; import { Server } from 'http';
import cors from 'cors'; import cors from 'cors';
import { applyArrayPlugins, Random, serverInformationEndpoint, settings } from 'shared'; import { Random, serverInformationEndpoint, settings } from 'shared';
import minimist from 'minimist'; import minimist from 'minimist';
import { glMatrix } from 'gl-matrix'; import { glMatrix } from 'gl-matrix';
import { GameServer } from './game-server'; import { GameServer } from './game-server';
@ -10,7 +10,6 @@ import { defaultOptions } from './options';
import parser from 'socket.io-msgpack-parser'; import parser from 'socket.io-msgpack-parser';
glMatrix.setMatrixArrayType(Array); glMatrix.setMatrixArrayType(Array);
applyArrayPlugins();
const optionOverrides = minimist(process.argv.slice(2)); const optionOverrides = minimist(process.argv.slice(2));
const options = { const options = {

View file

@ -2,16 +2,13 @@ import { vec2 } from 'gl-matrix';
import { import {
id, id,
settings, settings,
MoveActionCommand,
serializesTo,
Circle, Circle,
CharacterBase, CharacterBase,
CharacterTeam, CharacterTeam,
PropertyUpdatesForObject, PropertyUpdatesForObject,
UpdatePropertyCommand, UpdatePropertyCommand,
CommandExecutors,
CommandReceiver,
CharacterMovementSnapshot, CharacterMovementSnapshot,
GameObject,
mix, mix,
clamp01, clamp01,
strengthToCharge, strengthToCharge,
@ -20,6 +17,7 @@ import {
decayMomentum, decayMomentum,
tickPlanetDetachment, tickPlanetDetachment,
characterCenter, characterCenter,
sumGravity,
CharacterWorld, CharacterWorld,
GroundSurface, GroundSurface,
headRadius, headRadius,
@ -29,69 +27,45 @@ import {
rightFootOffset, rightFootOffset,
boundRadius, boundRadius,
} from 'shared'; } from 'shared';
import { DynamicPhysical } from '../physics/physicals/dynamic-physical'; import { Physical } from '../physics/physical';
import { CirclePhysical } from './circle-physical'; import { CirclePhysical } from './circle-physical';
import { PhysicalContainer } from '../physics/containers/physical-container'; import { PhysicalContainer } from '../physics/physical-container';
import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base'; import { BoundingBox } from '../physics/bounding-box';
import { ProjectilePhysical } from './projectile-physical'; import { ProjectilePhysical } from './projectile-physical';
import { forceAtPosition } from '../physics/functions/force-at-position'; import { PlanetPhysical, planetsIn } from './planet-physical';
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
import { PlanetPhysical } from './planet-physical';
import {
StepCommand,
ReactToCollisionCommand,
GeneratePointsCommand,
} from '../commands/commands';
interface BodyPose { const placeholder = new Circle(vec2.create(), 0);
head: Circle;
leftFoot: Circle;
rightFoot: Circle;
}
@serializesTo(CharacterBase) export class CharacterPhysical extends CharacterBase implements Physical {
export class CharacterPhysical extends CharacterBase implements DynamicPhysical {
public readonly canCollide = true; public readonly canCollide = true;
public readonly canMove = true;
private projectileStrength = settings.playerMaxStrength;
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;
// Held in place: the shared simulation reads and writes these directly, so
// the character IS the state it is stepped as (client predictor agrees).
public direction = 0;
public currentPlanet: GroundSurface | undefined;
public secondsSinceOnSurface = settings.planetDetachmentSeconds;
public readonly bodyVelocity = vec2.create();
private timeSinceLastLeap = settings.leapCooldownSeconds;
public head: CirclePhysical; public head: CirclePhysical;
public leftFoot: CirclePhysical; public leftFoot: CirclePhysical;
public rightFoot: CirclePhysical; public rightFoot: CirclePhysical;
private movementActions: Array<MoveActionCommand> = []; public direction = 0;
private lastMovementAction: MoveActionCommand = new MoveActionCommand(vec2.create()); public currentPlanet: GroundSurface | undefined;
public secondsSinceOnSurface = settings.planetDetachmentSeconds;
public readonly bodyVelocity = vec2.create();
private headVelocity = new Circle(vec2.create(), 0); private projectileStrength = settings.playerMaxStrength;
private leftFootVelocity = new Circle(vec2.create(), 0); private timeAlive = 0;
private rightFootVelocity = new Circle(vec2.create(), 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;
protected commandExecutors: CommandExecutors = { private movementDirections: Array<vec2> = [];
[StepCommand.type]: this.step.bind(this), private lastMovementDirection = vec2.create();
[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( constructor(
name: string, name: string,
@ -101,43 +75,42 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
private readonly container: PhysicalContainer, private readonly container: PhysicalContainer,
startPosition: vec2, startPosition: vec2,
) { ) {
super(id(), name, killCount, deathCount, team, settings.playerMaxHealth); super(
this.head = new CirclePhysical( id(),
vec2.add(vec2.create(), startPosition, headOffset), name,
headRadius, killCount,
this, deathCount,
container, team,
settings.playerMaxHealth,
placeholder,
placeholder,
placeholder,
); );
this.leftFoot = new CirclePhysical( const part = (offset: vec2, radius: number) =>
vec2.add(vec2.create(), startPosition, leftFootOffset), new CirclePhysical(
feetRadius, vec2.add(vec2.create(), startPosition, offset),
this, radius,
container, this,
); container,
this.rightFoot = new CirclePhysical( );
vec2.add(vec2.create(), startPosition, rightFootOffset), this.head = part(headOffset, headRadius);
feetRadius, this.leftFoot = part(leftFootOffset, feetRadius);
this, this.rightFoot = part(rightFootOffset, feetRadius);
container,
);
container.addObject(this.head);
container.addObject(this.leftFoot);
container.addObject(this.rightFoot);
} }
private readonly movementWorld: CharacterWorld = { private readonly movementWorld: CharacterWorld = {
// Same set and order forceAtPosition uses, so the f64 gravity sum matches.
groundsNear: (center, radius) => groundsNear: (center, radius) =>
this.container planetsIn(this.container.findIntersecting(BoundingBox.ofCircle(center, radius))),
.findIntersecting(getBoundingBoxOfCircle(new Circle(center, radius)))
.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical),
stepBody: (body, deltaTimeInSeconds) => { stepBody: (body, deltaTimeInSeconds) => {
const { hitObject } = (body as CirclePhysical).stepManually(deltaTimeInSeconds); const hit = (body as CirclePhysical).stepManually(deltaTimeInSeconds);
return hitObject instanceof PlanetPhysical ? hitObject : undefined; return hit instanceof PlanetPhysical ? hit : undefined;
}, },
}; };
private hasFiredSinceSpawn = false; private get parts(): Array<CirclePhysical> {
return [this.head, this.leftFoot, this.rightFoot];
}
private get isSpawnProtected(): boolean { private get isSpawnProtected(): boolean {
return ( return (
!this.hasFiredSinceSpawn && !this.hasFiredSinceSpawn &&
@ -146,38 +119,14 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
); );
} }
private hasGeneratedPoints = false;
private wasKilledInCombat = false;
private getPoints(game: CommandReceiver) {
if (!this.isAlive && !this.hasGeneratedPoints) {
this.hasGeneratedPoints = true;
if (!this.wasKilledInCombat) {
return;
}
const blue = this.team === CharacterTeam.blue ? 0 : settings.playerKillPoint;
const red = this.team === CharacterTeam.red ? 0 : settings.playerKillPoint;
game.handleCommand(new GeneratePointsCommand(blue, red));
}
}
public get isAlive(): boolean { public get isAlive(): boolean {
return !this.isDestroyed; return !this.isDestroyed;
} }
public handleMovementAction(c: MoveActionCommand) {
this.movementActions.push(c);
}
// The world only ever hands back planets, so narrowing GroundSurface is safe.
public get groundPlanet(): PlanetPhysical | undefined { public get groundPlanet(): PlanetPhysical | undefined {
return this.currentPlanet as PlanetPhysical | undefined; return this.currentPlanet as PlanetPhysical | undefined;
} }
// Continuous movement state streamed to the owning client so its predictor
// resumes this simulation instead of guessing. See CharacterMovementSnapshot.
public get movementSnapshot(): CharacterMovementSnapshot { public get movementSnapshot(): CharacterMovementSnapshot {
return new CharacterMovementSnapshot( return new CharacterMovementSnapshot(
this.direction, this.direction,
@ -189,6 +138,37 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
); );
} }
public get boundingBox(): BoundingBox {
const center = this.center;
this.box.xMin = center[0] - boundRadius;
this.box.xMax = center[0] + boundRadius;
this.box.yMin = center[1] - boundRadius;
this.box.yMax = center[1] + boundRadius;
return this.box;
}
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) {
this.movementDirections.push(direction);
}
public addKill(victimName: string, charge = 0) { public addKill(victimName: string, charge = 0) {
this.killCount++; this.killCount++;
this.killStreak++; this.killStreak++;
@ -214,38 +194,38 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
} }
} }
public onCollision({ other }: ReactToCollisionCommand) { public onCollision(other: GameObject) {
if ( if (
// A corpse keeps its collidable circles for the despawn animation; the !this.isAlive ||
// isAlive guard stops a flying corpse from eating shots aimed past it. !(other instanceof ProjectilePhysical) ||
this.isAlive && other.team === this.team ||
other instanceof ProjectilePhysical && !other.isAlive
other.team !== this.team &&
other.isAlive
) { ) {
other.destroy(); return;
}
if (this.isSpawnProtected) { other.destroy();
return;
}
this.timeSinceLastDamage = 0; if (this.isSpawnProtected) {
this.health -= other.strength; return;
this.lastSyncedHealth = Math.round(this.health); }
this.remoteCall('setHealth', this.health);
if (this.health <= 0 && this.isAlive) { this.timeSinceLastDamage = 0;
vec2.scaleAndAdd( this.health -= other.strength;
this.bodyVelocity, this.lastSyncedHealth = Math.round(this.health);
this.bodyVelocity, this.remoteCall('setHealth', this.health);
other.direction,
mix(settings.deathImpulseMin, settings.deathImpulseMax, other.charge), if (this.health <= 0) {
); vec2.scaleAndAdd(
this.onDie(true); this.bodyVelocity,
other.originator.addKill(this.name, other.charge); this.bodyVelocity,
} else { other.direction,
other.originator.registerHit(other.charge); mix(settings.deathImpulseMin, settings.deathImpulseMax, other.charge),
} );
this.onDie(true);
other.originator.addKill(this.name, other.charge);
} else {
other.originator.registerHit(other.charge);
} }
} }
@ -266,41 +246,37 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
this.timeSinceLastShot = 0; this.timeSinceLastShot = 0;
this.hasFiredSinceSpawn = true; this.hasFiredSinceSpawn = true;
const requestedCharge = clamp01(charge);
const desiredStrength = mix( const desiredStrength = mix(
settings.chargeShotStrengthMin, settings.chargeShotStrengthMin,
settings.chargeShotStrengthMax, settings.chargeShotStrengthMax,
requestedCharge, clamp01(charge),
); );
const strength = Math.min(desiredStrength, this.projectileStrength); const strength = Math.min(desiredStrength, this.projectileStrength);
this.projectileStrength -= strength; this.projectileStrength -= strength;
const c = strengthToCharge(strength); const c = strengthToCharge(strength);
const radius = mix(settings.chargeShotRadiusMin, settings.chargeShotRadiusMax, c); const radius = mix(settings.chargeShotRadiusMin, settings.chargeShotRadiusMax, c);
const speed = mix(settings.chargeShotSpeedMin, settings.chargeShotSpeedMax, c); const speed = mix(settings.chargeShotSpeedMin, settings.chargeShotSpeedMax, c);
vec2.normalize(direction, direction); vec2.normalize(direction, direction);
// Keep the unit direction before vec2.scale repurposes it as the velocity. this.container.addObject(
const shotDirection = vec2.clone(direction); new ProjectilePhysical(
const velocity = vec2.scale(direction, direction, speed); vec2.clone(this.center),
const projectile = new ProjectilePhysical( radius,
vec2.clone(this.center), strength,
radius, this.team,
strength, vec2.scale(vec2.create(), direction, speed),
this.team, this,
velocity, this.container,
this, c,
this.container, ),
c,
); );
this.container.addObject(projectile);
if (c > 0) { if (c > 0) {
vec2.scaleAndAdd( vec2.scaleAndAdd(
this.bodyVelocity, this.bodyVelocity,
this.bodyVelocity, this.bodyVelocity,
shotDirection, direction,
-settings.chargeShotRecoilMax * c, -settings.chargeShotRecoilMax * c,
); );
} }
@ -321,67 +297,32 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
this.timeSinceLastLeap = 0; this.timeSinceLastLeap = 0;
this.projectileStrength -= settings.leapStrengthCost; this.projectileStrength -= settings.leapStrengthCost;
applyLeapImpulse(this, this.lastMovementDirection);
// Same impulse the client predicts with (shared), so a leap launches
// identically on both sides.
applyLeapImpulse(this, this.lastMovementAction.direction);
this.remoteCall('onLeap'); this.remoteCall('onLeap');
} }
public get boundingBox(): BoundingBoxBase { public onDie(killedInCombat = false) {
return getBoundingBoxOfCircle(new Circle(this.center, boundRadius)); if (this.isDestroyed) {
} return;
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
);
}
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);
const actions = this.movementActions;
this.lastMovementAction = actions[actions.length - 1]!;
this.movementActions = [];
} }
this.isDestroyed = true;
this.remoteCall('onDie');
return vec2.length(direction) > 0 if (killedInCombat) {
? vec2.normalize(direction, direction) const points = settings.playerKillPoint;
: vec2.create(); this.container.game.addPoints(
} this.team === CharacterTeam.blue ? 0 : points,
this.team === CharacterTeam.red ? 0 : points,
private animateScaling(q: number) { );
this.head.radius = headRadius * q; }
this.leftFoot.radius = this.rightFoot.radius = feetRadius * q;
} }
public getPropertyUpdates(): PropertyUpdatesForObject { public getPropertyUpdates(): PropertyUpdatesForObject {
const [headVelocity, leftFootVelocity, rightFootVelocity] = this.velocities;
return new PropertyUpdatesForObject(this.id, [ return new PropertyUpdatesForObject(this.id, [
new UpdatePropertyCommand('head', this.head, this.headVelocity), new UpdatePropertyCommand('head', this.head, headVelocity),
new UpdatePropertyCommand('leftFoot', this.leftFoot, this.leftFootVelocity), new UpdatePropertyCommand('leftFoot', this.leftFoot, leftFootVelocity),
new UpdatePropertyCommand('rightFoot', this.rightFoot, this.rightFootVelocity), new UpdatePropertyCommand('rightFoot', this.rightFoot, rightFootVelocity),
new UpdatePropertyCommand( new UpdatePropertyCommand(
'strength', 'strength',
this.projectileStrength, this.projectileStrength,
@ -390,107 +331,51 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
]); ]);
} }
// `previous` is consumed as scratch so this allocates nothing beyond the public step(deltaTimeInSeconds: number) {
// Circle it returns.
private static rateOfChange(previous: Circle, current: Circle, deltaTime: number) {
return new Circle(
vec2.scale(
previous.center,
vec2.subtract(previous.center, current.center, previous.center),
1 / deltaTime,
),
(current.radius - previous.radius) / deltaTime,
);
}
private setPropertyUpdates(previous: BodyPose, deltaTime: number) {
const rate = CharacterPhysical.rateOfChange;
this.headVelocity = rate(previous.head, this.head, deltaTime);
this.leftFootVelocity = rate(previous.leftFoot, this.leftFoot, deltaTime);
this.rightFootVelocity = rate(previous.rightFoot, this.rightFoot, deltaTime);
}
private get pose(): BodyPose {
const snapshot = (circle: Circle) =>
new Circle(vec2.clone(circle.center), circle.radius);
return {
head: snapshot(this.head),
leftFoot: snapshot(this.leftFoot),
rightFoot: snapshot(this.rightFoot),
};
}
private step({ deltaTimeInSeconds, game }: StepCommand) {
this.getPoints(game);
this.timeAlive += deltaTimeInSeconds; this.timeAlive += deltaTimeInSeconds;
this.timeSinceLastLeap += deltaTimeInSeconds; this.timeSinceLastLeap += deltaTimeInSeconds;
const previousPose = this.pose; this.parts.forEach((part, i) => {
vec2.copy(this.previousPose[i].center, part.center);
this.previousPose[i].radius = part.radius;
});
if (this.isDestroyed) { if (this.isDestroyed) {
if ((this.timeSinceDying += deltaTimeInSeconds) > settings.spawnDespawnTime) { if ((this.timeSinceDying += deltaTimeInSeconds) > settings.spawnDespawnTime) {
this.destroy(); this.container.removeObject(this);
} else { } else {
this.freeFallCorpse(deltaTimeInSeconds); this.freeFallCorpse(deltaTimeInSeconds);
this.animateScaling(1 - this.timeSinceDying / settings.spawnDespawnTime); this.animateScaling(1 - this.timeSinceDying / settings.spawnDespawnTime);
} }
this.setPropertyUpdates(previousPose, deltaTimeInSeconds); } else if (this.hasJustBorn) {
return; if (this.timeAlive > settings.spawnDespawnTime) {
}
if (this.hasJustBorn) {
if ((this.timeSinceBorn += deltaTimeInSeconds) > settings.spawnDespawnTime) {
this.hasJustBorn = false; this.hasJustBorn = false;
this.animateScaling(1); this.animateScaling(1);
} else { } else {
this.animateScaling(this.timeSinceBorn / settings.spawnDespawnTime); this.animateScaling(this.timeAlive / settings.spawnDespawnTime);
} }
this.setPropertyUpdates(previousPose, deltaTimeInSeconds); } else {
return; this.stepAlive(deltaTimeInSeconds);
} }
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); tickPlanetDetachment(this, deltaTimeInSeconds);
this.timeSinceLastShot += deltaTimeInSeconds; this.timeSinceLastShot += deltaTimeInSeconds;
this.projectileStrength = Math.min( this.projectileStrength = Math.min(
settings.playerMaxStrength, settings.playerMaxStrength,
this.projectileStrength + this.projectileStrength +
settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds, settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds,
); );
this.regenerateHealth(deltaTimeInSeconds);
this.groundPlanet?.registerPresence(this);
// stepCharacterMovement is the shared simulation the client predicts with,
// so the two can never drift.
const direction = this.averageAndResetMovementActions();
stepCharacterMovement(this, this.movementWorld, direction, deltaTimeInSeconds);
this.setPropertyUpdates(previousPose, deltaTimeInSeconds);
}
private freeFallCorpse(deltaTime: number) {
const intersecting = this.container.findIntersecting(
getBoundingBoxOfCircle(
new Circle(this.center, 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 shared model the living body uses, 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; this.timeSinceLastDamage += deltaTimeInSeconds;
if ( if (
this.timeSinceLastDamage > settings.playerOutOfCombatDelaySeconds && this.timeSinceLastDamage > settings.playerOutOfCombatDelaySeconds &&
@ -502,18 +387,51 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
); );
this.syncHealth(); this.syncHealth();
} }
this.groundPlanet?.registerPresence(this);
stepCharacterMovement(
this,
this.movementWorld,
this.averageAndResetMovementDirections(),
deltaTimeInSeconds,
);
} }
public onDie(killedInCombat = false) { private averageAndResetMovementDirections(): vec2 {
this.wasKilledInCombat = killedInCombat; const direction = vec2.create();
this.isDestroyed = true; if (this.movementDirections.length === 0) {
this.remoteCall('onDie'); vec2.copy(direction, this.lastMovementDirection);
} else {
for (const d of this.movementDirections) {
vec2.add(direction, direction, d);
}
vec2.scale(direction, direction, 1 / this.movementDirections.length);
this.lastMovementDirection =
this.movementDirections[this.movementDirections.length - 1];
this.movementDirections = [];
}
return vec2.normalize(direction, direction);
} }
private destroy() { private animateScaling(q: number) {
this.container.removeObject(this); this.head.radius = headRadius * q;
this.container.removeObject(this.head); this.leftFoot.radius = this.rightFoot.radius = feetRadius * q;
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);
part.applyForce(sumGravity(planets, part.center), deltaTime);
if (part.stepManually(deltaTime) instanceof PlanetPhysical) {
grounded = true;
}
}
decayMomentum(this.bodyVelocity, grounded, deltaTime);
} }
} }

View file

@ -1,138 +1,72 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { import { applyForce, Circle, GameObject, resolveCircleMovement } from 'shared';
applyForce, import { BoundingBox } from '../physics/bounding-box';
Circle, import { PhysicalContainer } from '../physics/physical-container';
CommandExecutors, import { Physical } from '../physics/physical';
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 { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
import { ReactToCollisionCommand } from '../commands/commands';
@serializesTo(Circle)
export class CirclePhysical extends CommandReceiver implements Circle, DynamicPhysical {
readonly canCollide = true;
readonly canMove = true;
export class CirclePhysical extends Circle implements Physical {
public readonly canCollide = true;
public velocity = vec2.create(); public velocity = vec2.create();
public lastNormal = vec2.fromValues(0, 1); public lastNormal = vec2.fromValues(0, 1);
private _boundingBox: BoundingBox; private readonly box = new BoundingBox();
protected commandExecutors: CommandExecutors = {
[ReactToCollisionCommand.type]: this.onCollision.bind(this),
};
constructor( constructor(
private _center: vec2, center: vec2,
private _radius: number, radius: number,
public owner: GameObject, public readonly owner: Physical,
private readonly container: PhysicalContainer, private readonly container: PhysicalContainer,
// Public + readonly so CirclePhysical satisfies the shared PhysicsBody
// interface the movement simulation operates on.
public readonly restitution = 0, public readonly restitution = 0,
) { ) {
super(); super(center, radius);
this._boundingBox = new BoundingBox();
this.syncBoundingBox();
} }
public get boundingBox(): BoundingBoxBase { public get boundingBox(): BoundingBox {
return this._boundingBox; this.box.xMin = this.center[0] - this.radius;
} this.box.xMax = this.center[0] + this.radius;
this.box.yMin = this.center[1] - this.radius;
public get center(): vec2 { this.box.yMax = this.center[1] + this.radius;
return this._center; return this.box;
}
public set center(value: vec2) {
this._center = value;
this.syncBoundingBox();
}
public onCollision(c: ReactToCollisionCommand) {
this.owner.handleCommand(c);
} }
public get gameObject(): GameObject { public get gameObject(): GameObject {
return this.owner; return this.owner.gameObject;
} }
public get radius(): number { public onCollision(other: GameObject) {
return this._radius; this.owner.onCollision?.(other);
}
public set radius(value: number) {
this._radius = value;
this.syncBoundingBox();
}
public distance(target: vec2): number {
return vec2.distance(target, this.center) - this.radius;
}
public syncBoundingBox() {
this._boundingBox.xMin = this.center.x - this._radius;
this._boundingBox.xMax = this.center.x + this._radius;
this._boundingBox.yMin = this.center.y - this._radius;
this._boundingBox.yMax = this.center.y + this._radius;
} }
public applyForce(force: vec2, timeInSeconds: number) { public applyForce(force: vec2, timeInSeconds: number) {
applyForce(this, force, timeInSeconds); applyForce(this, force, timeInSeconds);
} }
// Delegates to the shared resolveCircleMovement so the server integrates with
// the exact same geometry the client predictor runs — no parallel copy to
// keep in sync. `possibleIntersectors` lets a caller that already broadphased
// avoid a second container query; otherwise it is self-gathered.
public stepManually( public stepManually(
deltaTimeInSeconds: number, deltaTimeInSeconds: number,
possibleIntersectors?: Array<Physical>, possibleIntersectors?: Array<Physical>,
): { ): GameObject | undefined {
hitObject: GameObject | undefined;
velocity: vec2;
} {
const intersecting = ( const intersecting = (
possibleIntersectors ?? this.sweptBroadphase(deltaTimeInSeconds) possibleIntersectors ?? this.sweptBroadphase(deltaTimeInSeconds)
).filter((b) => b.gameObject !== this.gameObject && b.canCollide); ).filter((b) => b.gameObject !== this.gameObject && b.canCollide);
const { hitObject, velocity } = resolveCircleMovement( const hit = resolveCircleMovement(
this, this,
deltaTimeInSeconds, deltaTimeInSeconds,
intersecting, intersecting,
(intersected) => { (intersected) => {
const physical = intersected as Physical; const physical = intersected as Physical;
physical.handleCommand(new ReactToCollisionCommand(this.gameObject)); physical.onCollision?.(this.gameObject);
this.handleCommand(new ReactToCollisionCommand(physical.gameObject)); this.onCollision(physical.gameObject);
}, },
); ) as Physical | undefined;
this.syncBoundingBox(); return hit?.gameObject;
return { hitObject: (hitObject as Physical | undefined)?.gameObject, velocity };
} }
private sweptBroadphase(deltaTimeInSeconds: number): Array<Physical> { private sweptBroadphase(deltaTimeInSeconds: number): Array<Physical> {
const sweep = vec2.length( const sweep = vec2.length(this.velocity) * deltaTimeInSeconds;
vec2.scale(vec2.create(), this.velocity, deltaTimeInSeconds),
);
// Grow the query box, not this.radius: mutating the radius would re-register
// the body's extent mid-tick via the setter.
return this.container.findIntersecting( return this.container.findIntersecting(
getBoundingBoxOfCircle(new Circle(this.center, this.radius + sweep)), BoundingBox.ofCircle(this.center, this.radius + sweep),
); );
} }
public toArray(): Array<any> {
const { center, radius } = this;
return [center, radius];
}
} }

View file

@ -1,29 +1,15 @@
import { vec2, vec3 } from 'gl-matrix'; import { vec2, vec3 } from 'gl-matrix';
import { Circle, 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'; export class LampPhysical extends LampBase implements Physical {
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
import { StaticPhysical } from '../physics/physicals/static-physical';
@serializesTo(LampBase)
export class LampPhysical extends LampBase implements StaticPhysical {
public readonly canCollide = false; public readonly canCollide = false;
public readonly canMove = false; public readonly boundingBox: BoundingBox;
constructor(center: vec2, color: vec3, lightness: number) { constructor(center: vec2, color: vec3, lightness: number) {
super(id(), center, color, lightness); super(id(), center, color, lightness);
} this.boundingBox = BoundingBox.ofCircle(center, settings.lightCutoffDistance);
private _boundingBox?: ImmutableBoundingBox;
public get boundingBox(): ImmutableBoundingBox {
if (!this._boundingBox) {
this._boundingBox = getBoundingBoxOfCircle(
new Circle(this.center, settings.lightCutoffDistance),
);
}
return this._boundingBox;
} }
public get gameObject(): this { public get gameObject(): this {

View file

@ -1,145 +1,90 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { import {
Circle,
clamp01, clamp01,
id, id,
mix, mix,
Random, Random,
serializesTo,
settings, settings,
PlanetBase, PlanetBase,
CharacterTeam, CharacterTeam,
PropertyUpdatesForObject, PropertyUpdatesForObject,
UpdatePropertyCommand, UpdatePropertyCommand,
planetDistance,
planetGravity,
CommandExecutors,
CommandReceiver,
} from 'shared'; } from 'shared';
import { import { GameEvents } from '../game-events';
GeneratePointsCommand, import { BoundingBox } from '../physics/bounding-box';
AnnounceCommand, import { Physical } from '../physics/physical';
StepCommand,
} from '../commands/commands';
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
import { StaticPhysical } from '../physics/physicals/static-physical';
import { LampPhysical } from './lamp-physical'; import { LampPhysical } from './lamp-physical';
import type { CharacterPhysical } from './character-physical'; import type { CharacterPhysical } from './character-physical';
@serializesTo(PlanetBase) export const planetsIn = (objects: Array<Physical>): Array<PlanetPhysical> =>
export class PlanetPhysical extends PlanetBase implements StaticPhysical { objects.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical);
public readonly canCollide = true;
public readonly canMove = false;
// Marks this as standable ground for the shared movement simulation.
public readonly isGround = true;
export class PlanetPhysical extends PlanetBase implements Physical {
public readonly boundingBox: BoundingBox;
public readonly sizePointMultiplier: number; public readonly sizePointMultiplier: number;
// Authoritative spin angle, streamed to the client so the rendered outline
// and this collision polygon turn as one. cos/sin are memoised: distance()
// is called many times per tick by the raymarcher and the angle rarely
// changes between those calls.
private rotation = 0;
private readonly rotationSpeed: number;
private cachedRotation = Number.NaN;
private cosRotation = 1;
private sinRotation = 0;
private _boundingBox?: ImmutableBoundingBox;
private readonly lamps: Array<LampPhysical> = []; private readonly lamps: Array<LampPhysical> = [];
private lastTeam: CharacterTeam = CharacterTeam.neutral; 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().
private presentCharacters: Array<CharacterPhysical> = []; private presentCharacters: Array<CharacterPhysical> = [];
private isContested = false; private isContested = false;
private timeSinceLastPointGeneration = 0;
protected commandExecutors: CommandExecutors = { constructor(
[StepCommand.type]: this.step.bind(this), vertices: Array<vec2>,
}; isKeystone: boolean,
private readonly game: GameEvents,
public addLamp(lamp: LampPhysical) { ) {
this.lamps.push(lamp);
}
constructor(vertices: Array<vec2>, isKeystone = false) {
super(id(), vertices, 0.5, isKeystone); super(id(), vertices, 0.5, isKeystone);
const sizeClass = clamp01( const sizeClass = clamp01(
(this.radius - settings.planetMinReferenceRadius) / (this.radius - settings.planetMinReferenceRadius) /
(settings.planetMaxReferenceRadius - settings.planetMinReferenceRadius), (settings.planetMaxReferenceRadius - settings.planetMinReferenceRadius),
); );
this.sizePointMultiplier = mix(1, settings.planetSizePointMultiplierMax, sizeClass); this.sizePointMultiplier = mix(1, settings.planetSizePointMultiplierMax, sizeClass);
this.angularVelocity =
this.rotationSpeed =
(0.05 + Random.getRandom() * 0.07) * (Random.getRandom() < 0.5 ? -1 : 1); (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);
} }
public registerPresence(character: CharacterPhysical) { public registerPresence(character: CharacterPhysical) {
this.presentCharacters.push(character); this.presentCharacters.push(character);
} }
public distance(target: vec2): number { public step(deltaTimeInSeconds: number) {
// The shared planet outline — the same function the client predictor this.advanceRotation(deltaTimeInSeconds);
// collides against, which is what lets prediction reconcile.
this.syncRotationTrigonometry();
return planetDistance(
target,
this.vertices,
this.center,
this.cosRotation,
this.sinRotation,
);
}
private syncRotationTrigonometry() {
if (this.rotation !== this.cachedRotation) {
this.cachedRotation = this.rotation;
this.cosRotation = Math.cos(this.rotation);
this.sinRotation = Math.sin(this.rotation);
}
}
// Signed angular velocity in rad/s, exposed so a character can ride the
// planet's spin (see carryWithRotatingPlanet in shared).
public get angularVelocity(): number {
return this.rotationSpeed;
}
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;
this.timeSinceLastPointGeneration += deltaTimeInSeconds; this.timeSinceLastPointGeneration += deltaTimeInSeconds;
// In reverse order, so that teams can achieve a 100% control. this.generatePoints();
this.getPoints(game);
this.resolveCapture(deltaTimeInSeconds); 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) {
const value = Math.round(base * this.sizePointMultiplier);
this.game.addPoints(
this.team === CharacterTeam.blue ? value : 0,
this.team === CharacterTeam.red ? value : 0,
);
} }
private resolveCapture(deltaTime: number) { private resolveCapture(deltaTime: number) {
@ -175,7 +120,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
} }
} }
private detectFlip(game: CommandReceiver) { private detectFlip() {
const currentTeam = this.team; const currentTeam = this.team;
if (currentTeam === this.lastTeam) { if (currentTeam === this.lastTeam) {
return; return;
@ -183,22 +128,15 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
this.lastTeam = currentTeam; this.lastTeam = currentTeam;
if (currentTeam !== CharacterTeam.neutral) { if (currentTeam !== CharacterTeam.neutral) {
const reward = Math.round( this.remoteCall(
settings.captureFlipPointReward * this.sizePointMultiplier, 'generatedPoints',
); 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.awardToOwner(settings.captureFlipPointReward);
if (this.isKeystone) { if (this.isKeystone) {
game.handleCommand( this.game.announce(
new AnnounceCommand( `Team <span class="${currentTeam}">${currentTeam}</span> captured the Heart`,
`Team <span class="${currentTeam}">${currentTeam}</span> captured the Heart`,
),
); );
} }
} }
@ -206,22 +144,12 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
const control = Math.abs(this.ownership - 0.5) / 0.5; const control = Math.abs(this.ownership - 0.5) / 0.5;
const lightness = mix(settings.lampMinLightness, settings.lampMaxLightness, control); const lightness = mix(settings.lampMinLightness, settings.lampMaxLightness, control);
const color = settings.palette[settings.colorIndices[currentTeam]]; const color = settings.palette[settings.colorIndices[currentTeam]];
this.lamps.forEach((lamp) => lamp.queueSetLight(color, lightness)); this.lamps.forEach((lamp) => lamp.queueSetLight(color, lightness));
this.remoteCall('onFlipped', currentTeam); this.remoteCall('onFlipped', currentTeam);
} }
public getPropertyUpdates(): PropertyUpdatesForObject { private takeControl(team: CharacterTeam, deltaTime: number) {
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) {
if (team === CharacterTeam.blue) { if (team === CharacterTeam.blue) {
this.ownership -= (0.5 / settings.takeControlTimeInSeconds) * deltaTime; this.ownership -= (0.5 / settings.takeControlTimeInSeconds) * deltaTime;
} else if (team === CharacterTeam.red) { } else if (team === CharacterTeam.red) {
@ -232,10 +160,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
-Math.sign(this.ownership - 0.5) * -Math.sign(this.ownership - 0.5) *
(0.5 / settings.loseControlTimeInSeconds) * (0.5 / settings.loseControlTimeInSeconds) *
deltaTime; deltaTime;
if ( if ((previous - 0.5) * (this.ownership - 0.5) < 0) {
(previous < 0.5 && this.ownership > 0.5) ||
(previous > 0.5 && this.ownership < 0.5)
) {
this.ownership = 0.5; this.ownership = 0.5;
} }
} }
@ -243,30 +168,10 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
this.ownership = clamp01(this.ownership); this.ownership = clamp01(this.ownership);
} }
public get boundingBox(): ImmutableBoundingBox { public getPropertyUpdates(): PropertyUpdatesForObject {
if (!this._boundingBox) { return new PropertyUpdatesForObject(this.id, [
// The polygon spins about its centre (see distance), so this static box new UpdatePropertyCommand('ownership', this.ownership, 0),
// has to cover every orientation, not just the spawn-time one: take the new UpdatePropertyCommand('rotation', this.rotation, this.angularVelocity),
// circumscribed circle around the rotation centre. ]);
const maxVertexDistance = this.vertices.reduce(
(max, vertex) => Math.max(max, vec2.distance(this.center, vertex)),
0,
);
this._boundingBox = getBoundingBoxOfCircle(
new Circle(this.center, maxVertexDistance),
);
}
return this._boundingBox;
}
// GroundSurface gravity, shared with the client predictor.
public gravityAt(position: vec2): vec2 {
return planetGravity(this.center, this.radius, position);
}
public get gameObject(): this {
return this;
} }
} }

View file

@ -2,55 +2,42 @@ import { vec2 } from 'gl-matrix';
import { import {
id, id,
settings, settings,
serializesTo,
ProjectileBase, ProjectileBase,
CharacterTeam, CharacterTeam,
PropertyUpdatesForObject, PropertyUpdatesForObject,
UpdatePropertyCommand, UpdatePropertyCommand,
CommandExecutors, GameObject,
Circle, evaluateSdf,
marchCircle, sumGravity,
} from 'shared'; } from 'shared';
import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base'; import { BoundingBox } from '../physics/bounding-box';
import { CirclePhysical } from './circle-physical'; import { CirclePhysical } from './circle-physical';
import { DynamicPhysical } from '../physics/physicals/dynamic-physical'; import { Physical } from '../physics/physical';
import { PhysicalContainer } from '../physics/containers/physical-container'; import { PhysicalContainer } from '../physics/physical-container';
import { CharacterPhysical } from './character-physical'; import { CharacterPhysical } from './character-physical';
import { forceAtPosition } from '../physics/functions/force-at-position'; import { planetsIn } from './planet-physical';
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
import { StepCommand, ReactToCollisionCommand } from '../commands/commands';
@serializesTo(ProjectileBase) export class ProjectilePhysical extends ProjectileBase implements Physical {
export class ProjectilePhysical extends ProjectileBase implements DynamicPhysical {
public readonly canCollide = true; public readonly canCollide = true;
public readonly canMove = true; public readonly object: CirclePhysical;
private isDestroyed = false; private isDestroyed = false;
private bounceCount = 0; private bounceCount = 0;
public object: CirclePhysical;
protected commandExecutors: CommandExecutors = {
[StepCommand.type]: this.handleStep.bind(this),
[ReactToCollisionCommand.type]: this.onCollision.bind(this),
};
constructor( constructor(
center: vec2, center: vec2,
radius: number, radius: number,
public strength: number, public strength: number,
team: CharacterTeam, team: CharacterTeam,
private velocity: vec2, velocity: vec2,
public readonly originator: CharacterPhysical, public readonly originator: CharacterPhysical,
readonly container: PhysicalContainer, private readonly container: PhysicalContainer,
// Normalised charge (0..1) of the shot that fired this; the victim scales
// hit/kill feedback and the death fling by it.
public readonly charge: number = 0, public readonly charge: number = 0,
) { ) {
super(id(), center, radius, team, strength); super(id(), center, radius, team, strength);
this.object = new CirclePhysical(center, radius, this, container, 0.9); this.object = new CirclePhysical(this.center, radius, this, container, 0.9);
vec2.copy(this.object.velocity, velocity);
this.moveOutsideOfObject(); this.moveOutsideFriendlyBodies();
} }
public get isAlive(): boolean { public get isAlive(): boolean {
@ -58,35 +45,13 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
} }
public get direction(): vec2 { public get direction(): vec2 {
const direction = vec2.clone(this.velocity); const direction = vec2.clone(this.object.velocity);
return vec2.length(direction) > 0 return vec2.length(direction) > 0
? vec2.normalize(direction, direction) ? vec2.normalize(direction, direction)
: vec2.fromValues(0, -1); : vec2.fromValues(0, -1);
} }
private moveOutsideOfObject() { public get boundingBox(): BoundingBox {
let wasCollision = true;
const delta = vec2.scale(
vec2.create(),
vec2.normalize(vec2.create(), this.velocity),
10,
);
// Bounded: each pass advances by `delta`, but a degenerate direction would
// otherwise spin here forever inside the physics tick.
let passes = 0;
while (wasCollision && passes++ < 32) {
const intersecting = this.container
.findIntersecting(this.boundingBox)
.filter((g) => g instanceof CharacterPhysical && g.team === this.team);
const { hitSurface } = marchCircle(this.object, delta, intersecting, true);
wasCollision = hitSurface;
this.object.syncBoundingBox();
}
}
// Live view of the circle's box, not a snapshot — the circle keeps it in sync
// as it moves.
public get boundingBox(): BoundingBoxBase {
return this.object.boundingBox; return this.object.boundingBox;
} }
@ -98,6 +63,19 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
return this.object.distance(target); 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() { public destroy() {
if (!this.isDestroyed) { if (!this.isDestroyed) {
this.isDestroyed = true; this.isDestroyed = true;
@ -105,7 +83,7 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
} }
} }
public onCollision({ other }: ReactToCollisionCommand) { public onCollision(other: GameObject) {
if ( if (
!(other instanceof CharacterPhysical && other.team === this.team) && !(other instanceof CharacterPhysical && other.team === this.team) &&
this.bounceCount++ === settings.projectileMaxBounceCount this.bounceCount++ === settings.projectileMaxBounceCount
@ -116,15 +94,11 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
public getPropertyUpdates(): PropertyUpdatesForObject { public getPropertyUpdates(): PropertyUpdatesForObject {
return new PropertyUpdatesForObject(this.id, [ return new PropertyUpdatesForObject(this.id, [
new UpdatePropertyCommand('center', this.center, this.velocity), new UpdatePropertyCommand('center', this.center, this.object.velocity),
]); ]);
} }
private handleStep({ deltaTimeInSeconds }: StepCommand) { public step(deltaTimeInSeconds: number) {
this.advance(deltaTimeInSeconds);
}
private advance(deltaTimeInSeconds: number) {
super.step(deltaTimeInSeconds); super.step(deltaTimeInSeconds);
if (this.strength <= 0) { if (this.strength <= 0) {
@ -132,24 +106,17 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
return; return;
} }
// 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 = this.container.findIntersecting( const intersecting = this.container.findIntersecting(
getBoundingBoxOfCircle( BoundingBox.ofCircle(this.center, this.radius + settings.maxGravityDistance),
new Circle(this.center, this.object.radius + settings.maxGravityDistance),
),
); );
vec2.scaleAndAdd( vec2.scaleAndAdd(
this.velocity, this.object.velocity,
this.velocity, this.object.velocity,
forceAtPosition(this.center, intersecting), sumGravity(planetsIn(intersecting), this.center),
settings.projectileGravityScale * deltaTimeInSeconds, settings.projectileGravityScale * deltaTimeInSeconds,
); );
vec2.copy(this.object.velocity, this.velocity); this.object.stepManually(deltaTimeInSeconds, intersecting);
const { velocity } = this.object.stepManually(deltaTimeInSeconds, intersecting);
vec2.copy(this.velocity, velocity);
} }
} }

View file

@ -1,54 +1,28 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { BoundingBoxBase } from './bounding-box-base';
export class BoundingBox extends BoundingBoxBase { export class BoundingBox {
public get xMin(): number { constructor(
return this._xMin; 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 set xMin(value: number) { public intersects(other: BoundingBox): boolean {
this._xMin = value; return (
} this.xMin < other.xMax &&
this.xMax > other.xMin &&
public set xMax(value: number) { this.yMin < other.yMax &&
this._xMax = value; this.yMax > other.yMin
} );
public get xMax(): number {
return this._xMax;
}
public set yMin(value: number) {
this._yMin = value;
}
public get yMin(): number {
return this._yMin;
}
public set yMax(value: number) {
this._yMax = value;
}
public get yMax(): number {
return this._yMax;
}
public get topLeft(): vec2 {
return vec2.fromValues(this._xMin, this._yMax);
}
public set topLeft(value: vec2) {
this._xMin = value.x;
this._yMax = value.y;
}
public set size(value: vec2) {
this._xMax = this.xMin + value.x;
this._yMin = this.yMax - value.y;
}
public get size(): vec2 {
return vec2.fromValues(this._xMax - this._xMin, this._yMax - this._yMin);
} }
} }

View file

@ -1,54 +1,29 @@
import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base'; import { GameEvents } from '../game-events';
import { BoundingBoxList } from './bounding-box-list'; import { BoundingBox } from './bounding-box';
import { BoundingBoxTree } from './bounding-box-tree'; import { Physical } from './physical';
import { Physical } from '../physicals/physical';
import { StaticPhysical } from '../physicals/static-physical';
import { DynamicPhysical } from '../physicals/dynamic-physical';
import { Command, CommandReceiver } from 'shared';
export class PhysicalContainer extends CommandReceiver { export class PhysicalContainer {
private isTreeInitialized = false;
private staticBoundingBoxesWaitList: Array<StaticPhysical> = [];
private staticBoundingBoxes = new BoundingBoxTree();
private dynamicBoundingBoxes = new BoundingBoxList();
private objects: Array<Physical> = []; private objects: Array<Physical> = [];
protected defaultCommandExecutor(c: Command) { constructor(public readonly game: GameEvents) {}
this.objects.forEach((o) => o.handleCommand(c));
}
public initialize() {
this.staticBoundingBoxes.build(this.staticBoundingBoxesWaitList);
this.isTreeInitialized = true;
}
public addObject(physical: Physical) { public addObject(physical: Physical) {
this.objects.push(physical); this.objects.push(physical);
if (physical.canMove) {
this.dynamicBoundingBoxes.insert(physical);
} else {
if (!this.isTreeInitialized) {
this.staticBoundingBoxesWaitList.push(physical);
} else {
this.staticBoundingBoxes.insert(physical);
}
}
} }
public removeObject(object: DynamicPhysical) { public removeObject(physical: Physical) {
this.objects = this.objects.filter((p) => p !== object); this.objects = this.objects.filter((o) => o !== physical);
this.dynamicBoundingBoxes.remove(object); }
public step(deltaTimeInSeconds: number) {
this.objects.forEach((o) => o.step?.(deltaTimeInSeconds));
} }
public resetRemoteCalls() { public resetRemoteCalls() {
this.objects.forEach((o) => o.gameObject.resetRemoteCalls()); this.objects.forEach((o) => o.gameObject.resetRemoteCalls());
} }
public findIntersecting(box: BoundingBoxBase): Array<Physical> { public findIntersecting(box: BoundingBox): Array<Physical> {
return [ return this.objects.filter((o) => o.boundingBox.intersects(box));
...this.staticBoundingBoxes.findIntersecting(box),
...this.dynamicBoundingBoxes.findIntersecting(box),
];
} }
} }

View file

@ -1,4 +1,13 @@
import { DynamicPhysical } from './dynamic-physical'; import { vec2 } from 'gl-matrix';
import { StaticPhysical } from './static-physical'; import { GameObject } from 'shared';
import { BoundingBox } from './bounding-box';
export type Physical = StaticPhysical | DynamicPhysical; export interface Physical {
readonly canCollide: boolean;
readonly boundingBox: BoundingBox;
readonly gameObject: GameObject;
distance(target: vec2): number;
step?(deltaTimeInSeconds: number): void;
onCollision?(other: GameObject): void;
}

View file

@ -1,23 +1,15 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { import { PlayerInformation, settings, Random, CharacterTeam, Id } from 'shared';
PlayerInformation, import { PhysicalContainer } from '../physics/physical-container';
settings, import { BoundingBox } from '../physics/bounding-box';
Circle,
Random,
MoveActionCommand,
CharacterTeam,
Id,
} from 'shared';
import { PhysicalContainer } from '../physics/containers/physical-container';
import { PlayerContainer } from './player-container'; import { PlayerContainer } from './player-container';
import { PlayerBase } from './player-base'; import { PlayerBase } from './player-base';
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
import { CharacterPhysical } from '../objects/character-physical'; import { CharacterPhysical } from '../objects/character-physical';
import { PlanetPhysical } from '../objects/planet-physical'; import { PlanetPhysical, planetsIn } from '../objects/planet-physical';
import { ProjectilePhysical } from '../objects/projectile-physical'; import { ProjectilePhysical } from '../objects/projectile-physical';
import { Physical } from '../physics/physicals/physical'; import { Physical } from '../physics/physical';
const npcTuning = { const tuning = {
planIntervalSeconds: 1, planIntervalSeconds: 1,
shootIntervalSeconds: 1.5, shootIntervalSeconds: 1.5,
reactionIntervalSeconds: 1 / 10, reactionIntervalSeconds: 1 / 10,
@ -40,7 +32,6 @@ const npcTuning = {
dodgeThreatRange: 450, dodgeThreatRange: 450,
dodgeApproachDot: 0.6, dodgeApproachDot: 0.6,
dodgeBaseChance: 0.25, dodgeBaseChance: 0.25,
dodgeAggressionChance: 0.35, dodgeAggressionChance: 0.35,
dodgeCommitSeconds: 0.35, dodgeCommitSeconds: 0.35,
@ -64,27 +55,33 @@ const npcTuning = {
leapChancePerSecond: 0.35, leapChancePerSecond: 0.35,
}; };
interface Enemy {
character: CharacterPhysical;
distance: number;
}
export class NPC extends PlayerBase { export class NPC extends PlayerBase {
private direction = vec2.fromValues(Random.getRandom() - 0.5, Random.getRandom() - 0.5); private direction = vec2.fromValues(Random.getRandom() - 0.5, Random.getRandom() - 0.5);
private timeSinceLastPlan = 10000; private movement = vec2.create();
private timeSinceLastShoot = 10000; private timeSinceLastPlan = Infinity;
private isWandering = false; private timeSinceLastShoot = Infinity;
private timeSinceObserve = Infinity;
private timeSinceLastWanderingConsideration = 0; private timeSinceLastWanderingConsideration = 0;
private isWandering = false;
private isComingBack = false; private isComingBack = false;
private readonly aggression = Random.getRandomInRange( private readonly aggression = Random.getRandomInRange(
npcTuning.aggressionMin, tuning.aggressionMin,
npcTuning.aggressionMax, tuning.aggressionMax,
); );
private aimTargetId: Id = null; private aimTargetId: Id | null = null;
private readonly aimTargetLastPosition = vec2.create(); private readonly aimTargetLastPosition = vec2.create();
private readonly dodgeDirection = vec2.create(); private readonly dodgeDirection = vec2.create();
private dodgeCommitRemaining = 0; private dodgeCommitRemaining = 0;
private dodgeCooldownRemaining = 0; private dodgeCooldownRemaining = 0;
private timeSinceObserve = npcTuning.reactionIntervalSeconds;
private nearObjects: Array<Physical> = []; private nearObjects: Array<Physical> = [];
constructor( constructor(
@ -95,7 +92,6 @@ export class NPC extends PlayerBase {
) { ) {
super(playerInfo, playerContainer, objectContainer, team); super(playerInfo, playerContainer, objectContainer, team);
this.createCharacter(); this.createCharacter();
this.step(0);
} }
public step(deltaTimeInSeconds: number) { public step(deltaTimeInSeconds: number) {
@ -106,44 +102,40 @@ export class NPC extends PlayerBase {
if ( if (
(this.timeSinceLastWanderingConsideration += deltaTimeInSeconds) > (this.timeSinceLastWanderingConsideration += deltaTimeInSeconds) >
npcTuning.wanderReconsiderSeconds tuning.wanderReconsiderSeconds
) { ) {
this.timeSinceLastWanderingConsideration = 0; this.timeSinceLastWanderingConsideration = 0;
this.isWandering = Random.getRandom() < npcTuning.wanderProbability; this.isWandering = Random.getRandom() < tuning.wanderProbability;
} }
if ((this.timeSinceLastPlan += deltaTimeInSeconds) > npcTuning.planIntervalSeconds) { if ((this.timeSinceLastPlan += deltaTimeInSeconds) > tuning.planIntervalSeconds) {
this.timeSinceLastPlan = 0; this.timeSinceLastPlan = 0;
this.plan(); this.plan();
} }
if (
(this.timeSinceObserve += deltaTimeInSeconds) > npcTuning.reactionIntervalSeconds
) {
this.timeSinceObserve = 0;
this.nearObjects = this.observe(npcTuning.reactionObserveRadius);
}
this.dodgeCommitRemaining -= deltaTimeInSeconds; this.dodgeCommitRemaining -= deltaTimeInSeconds;
this.dodgeCooldownRemaining -= deltaTimeInSeconds; this.dodgeCooldownRemaining -= deltaTimeInSeconds;
const movement = this.decideMovement(this.nearObjects);
character.handleMovementAction(new MoveActionCommand(movement)); if ((this.timeSinceObserve += deltaTimeInSeconds) > tuning.reactionIntervalSeconds) {
this.timeSinceObserve = 0;
this.nearObjects = this.observe(tuning.reactionObserveRadius);
this.movement = this.decideMovement(character, this.nearObjects);
character.setMoveDirection(this.movement);
}
if ( if (
!this.isComingBack && !this.isComingBack &&
character.groundPlanet && character.groundPlanet &&
vec2.length(movement) > 0 && vec2.length(this.movement) > 0 &&
Random.getRandom() < Random.getRandom() <
npcTuning.leapChancePerSecond * this.aggression * deltaTimeInSeconds tuning.leapChancePerSecond * this.aggression * deltaTimeInSeconds
) { ) {
character.leap(); character.leap();
} }
if ( if ((this.timeSinceLastShoot += deltaTimeInSeconds) > tuning.shootIntervalSeconds) {
(this.timeSinceLastShoot += deltaTimeInSeconds) > npcTuning.shootIntervalSeconds
) {
this.timeSinceLastShoot = 0; this.timeSinceLastShoot = 0;
this.tryShoot(this.nearObjects); this.tryShoot(character, this.nearObjects);
} }
} }
@ -159,20 +151,18 @@ export class NPC extends PlayerBase {
} }
this.isComingBack = false; this.isComingBack = false;
const nearObjects = this.observe(npcTuning.planScanRadius); const nearObjects = this.observe(tuning.planScanRadius);
const enemies = this.enemiesByDistance(nearObjects); const nearest = this.enemiesByDistance(nearObjects)[0];
const nearest = enemies[0];
if (nearest) { if (nearest) {
const fleeRange = const fleeRange =
npcTuning.fleeBaseRange * (npcTuning.fleeAggressionFalloff - this.aggression); tuning.fleeBaseRange * (tuning.fleeAggressionFalloff - this.aggression);
if (nearest.distance < fleeRange) { if (nearest.distance < fleeRange) {
vec2.subtract(this.direction, this.center, nearest.character.center); vec2.subtract(this.direction, this.center, nearest.character.center);
return; return;
} }
const chaseRange = const chaseRange =
npcTuning.chaseBaseRange + npcTuning.chaseAggressionRange * this.aggression; tuning.chaseBaseRange + tuning.chaseAggressionRange * this.aggression;
if (nearest.distance < chaseRange) { if (nearest.distance < chaseRange) {
vec2.subtract(this.direction, nearest.character.center, this.center); vec2.subtract(this.direction, nearest.character.center, this.center);
return; return;
@ -182,7 +172,7 @@ export class NPC extends PlayerBase {
if (!this.isWandering) { if (!this.isWandering) {
const planet = this.capturablePlanetsByDistance(nearObjects)[0]; const planet = this.capturablePlanetsByDistance(nearObjects)[0];
if (planet) { if (planet) {
vec2.subtract(this.direction, planet.planet.center, this.center); vec2.subtract(this.direction, planet.center, this.center);
return; return;
} }
} }
@ -191,11 +181,14 @@ export class NPC extends PlayerBase {
this.direction, this.direction,
this.direction, this.direction,
vec2.create(), vec2.create(),
Random.getRandomInRange(-npcTuning.wanderTurn, npcTuning.wanderTurn), Random.getRandomInRange(-tuning.wanderTurn, tuning.wanderTurn),
); );
} }
private decideMovement(nearObjects: Array<Physical>): vec2 { private decideMovement(
character: CharacterPhysical,
nearObjects: Array<Physical>,
): vec2 {
if (this.dodgeCommitRemaining > 0) { if (this.dodgeCommitRemaining > 0) {
return vec2.clone(this.dodgeDirection); return vec2.clone(this.dodgeDirection);
} }
@ -204,23 +197,20 @@ export class NPC extends PlayerBase {
if (dodge) { if (dodge) {
if ( if (
Random.getRandom() < Random.getRandom() <
npcTuning.dodgeBaseChance + npcTuning.dodgeAggressionChance * this.aggression tuning.dodgeBaseChance + tuning.dodgeAggressionChance * this.aggression
) { ) {
vec2.copy(this.dodgeDirection, dodge); vec2.copy(this.dodgeDirection, dodge);
this.dodgeCommitRemaining = npcTuning.dodgeCommitSeconds; this.dodgeCommitRemaining = tuning.dodgeCommitSeconds;
return vec2.clone(this.dodgeDirection); return vec2.clone(this.dodgeDirection);
} }
this.dodgeCooldownRemaining = npcTuning.dodgeCooldownSeconds; this.dodgeCooldownRemaining = tuning.dodgeCooldownSeconds;
} }
} }
const planet = this.character!.groundPlanet; const planet = character.groundPlanet;
if (planet && planet.team !== this.team) { if (planet && planet.team !== this.team) {
const enemies = this.enemiesByDistance(nearObjects); const enemies = this.enemiesByDistance(nearObjects);
if ( if (enemies.length === 0 || enemies[0].distance > tuning.captureHoldEnemyDistance) {
enemies.length === 0 ||
enemies[0].distance > npcTuning.captureHoldEnemyDistance
) {
return vec2.create(); return vec2.create();
} }
} }
@ -239,12 +229,12 @@ export class NPC extends PlayerBase {
} }
const toMe = vec2.subtract(vec2.create(), this.center, p.center); const toMe = vec2.subtract(vec2.create(), this.center, p.center);
const distance = vec2.length(toMe); const distance = vec2.length(toMe);
if (distance > npcTuning.dodgeThreatRange || distance === 0) { if (distance > tuning.dodgeThreatRange || distance === 0) {
continue; continue;
} }
vec2.normalize(toMe, toMe); vec2.normalize(toMe, toMe);
if ( if (
vec2.dot(p.direction, toMe) > npcTuning.dodgeApproachDot && vec2.dot(p.direction, toMe) > tuning.dodgeApproachDot &&
distance < threatDistance distance < threatDistance
) { ) {
threatDistance = distance; threatDistance = distance;
@ -261,14 +251,11 @@ export class NPC extends PlayerBase {
if (vec2.dot(perpendicular, toMe) < 0) { if (vec2.dot(perpendicular, toMe) < 0) {
vec2.negate(perpendicular, perpendicular); vec2.negate(perpendicular, perpendicular);
} }
vec2.normalize(perpendicular, perpendicular); return vec2.normalize(perpendicular, perpendicular);
return perpendicular;
} }
private tryShoot(nearObjects: Array<Physical>) { private tryShoot(character: CharacterPhysical, nearObjects: Array<Physical>) {
const enemies = this.enemiesByDistance(nearObjects); const visible = this.enemiesByDistance(nearObjects).find((e) =>
const visible = enemies.find((e) =>
this.hasLineOfSightTo(e.character.center, nearObjects), this.hasLineOfSightTo(e.character.center, nearObjects),
); );
if (!visible) { if (!visible) {
@ -276,71 +263,69 @@ export class NPC extends PlayerBase {
return; return;
} }
const target = visible.character; const { character: target, distance } = visible;
const distance = visible.distance;
const velocity = vec2.create(); const velocity = vec2.create();
if (this.aimTargetId === target.id) { if (this.aimTargetId === target.id) {
vec2.subtract(velocity, target.center, this.aimTargetLastPosition); vec2.subtract(velocity, target.center, this.aimTargetLastPosition);
vec2.scale(velocity, velocity, 1 / npcTuning.shootIntervalSeconds); vec2.scale(velocity, velocity, 1 / tuning.shootIntervalSeconds);
} }
this.aimTargetId = target.id; this.aimTargetId = target.id;
vec2.copy(this.aimTargetLastPosition, target.center); vec2.copy(this.aimTargetLastPosition, target.center);
if ( if (
Random.getRandom() > Random.getRandom() >
npcTuning.fireBaseChance + npcTuning.fireAggressionChance * this.aggression tuning.fireBaseChance + tuning.fireAggressionChance * this.aggression
) { ) {
return; return;
} }
const charge = const charge =
distance > npcTuning.chargeRangeThreshold && distance > tuning.chargeRangeThreshold &&
Random.getRandom() < Random.getRandom() <
npcTuning.chargeBaseChance + npcTuning.chargeAggressionChance * this.aggression tuning.chargeBaseChance + tuning.chargeAggressionChance * this.aggression
? Random.getRandomInRange(npcTuning.chargeMin, 1) ? Random.getRandomInRange(tuning.chargeMin, 1)
: 0; : 0;
const projectileSpeed = const projectileSpeed =
charge > 0 ? settings.chargeShotSpeedMax : settings.chargeShotSpeedMin; charge > 0 ? settings.chargeShotSpeedMax : settings.chargeShotSpeedMin;
const leadTime = distance / projectileSpeed; const aim = vec2.scaleAndAdd(
const aim = vec2.scaleAndAdd(vec2.create(), target.center, velocity, leadTime); vec2.create(),
target.center,
velocity,
distance / projectileSpeed,
);
const spread = const spread =
(npcTuning.spreadBase + distance * npcTuning.spreadPerDistance) * (tuning.spreadBase + distance * tuning.spreadPerDistance) *
(npcTuning.spreadAggressionFalloff - this.aggression); (tuning.spreadAggressionFalloff - this.aggression);
aim.x += Random.getRandomInRange(-spread, spread); aim[0] += Random.getRandomInRange(-spread, spread);
aim.y += Random.getRandomInRange(-spread, spread); aim[1] += Random.getRandomInRange(-spread, spread);
this.character!.shootTowards(aim, charge); character.shootTowards(aim, charge);
} }
private hasLineOfSightTo(target: vec2, nearObjects: Array<Physical>): boolean { private hasLineOfSightTo(target: vec2, nearObjects: Array<Physical>): boolean {
const planets: Array<PlanetPhysical> = []; const planets = planetsIn(nearObjects);
for (const o of nearObjects) {
if (o.gameObject instanceof PlanetPhysical) {
planets.push(o.gameObject);
}
}
if (planets.length === 0) { if (planets.length === 0) {
return true; return true;
} }
const direction = vec2.subtract(vec2.create(), target, this.center); const direction = vec2.subtract(vec2.create(), target, this.center);
const totalDistance = vec2.length(direction); const totalDistance = vec2.length(direction);
if (totalDistance <= npcTuning.lineOfSightStartOffset) { if (totalDistance <= tuning.lineOfSightStartOffset) {
return true; return true;
} }
vec2.normalize(direction, direction); vec2.normalize(direction, direction);
let traveled = npcTuning.lineOfSightStartOffset; let traveled = tuning.lineOfSightStartOffset;
const position = vec2.scaleAndAdd(vec2.create(), this.center, direction, traveled); const position = vec2.scaleAndAdd(vec2.create(), this.center, direction, traveled);
while (traveled < totalDistance) { while (traveled < totalDistance) {
let sdf = Infinity; let sdf = Infinity;
for (const planet of planets) { for (const planet of planets) {
sdf = Math.min(sdf, planet.distance(position)); sdf = Math.min(sdf, planet.distance(position));
} }
if (sdf < npcTuning.lineOfSightClearance) { if (sdf < tuning.lineOfSightClearance) {
return false; return false;
} }
traveled += sdf; traveled += sdf;
@ -351,45 +336,34 @@ export class NPC extends PlayerBase {
private observe(radius: number): Array<Physical> { private observe(radius: number): Array<Physical> {
return this.objectContainer.findIntersecting( return this.objectContainer.findIntersecting(
getBoundingBoxOfCircle(new Circle(this.center, radius)), BoundingBox.ofCircle(this.center, radius),
); );
} }
private enemiesByDistance( private enemiesByDistance(nearObjects: Array<Physical>): Array<Enemy> {
nearObjects: Array<Physical>, const enemies: Array<Enemy> = [];
): Array<{ character: CharacterPhysical; distance: number }> {
const seen = new Set<CharacterPhysical>();
const enemies: Array<{ character: CharacterPhysical; distance: number }> = [];
for (const o of nearObjects) { for (const o of nearObjects) {
const c = o.gameObject; const c = o.gameObject;
if ( if (
c instanceof CharacterPhysical && c instanceof CharacterPhysical &&
c !== this.character && c !== this.character &&
c.isAlive && c.isAlive &&
c.team !== this.team && c.team !== this.team
!seen.has(c)
) { ) {
seen.add(c);
enemies.push({ character: c, distance: vec2.distance(this.center, c.center) }); enemies.push({ character: c, distance: vec2.distance(this.center, c.center) });
} }
} }
enemies.sort((a, b) => a.distance - b.distance); return enemies.sort((a, b) => a.distance - b.distance);
return enemies;
} }
private capturablePlanetsByDistance( private capturablePlanetsByDistance(
nearObjects: Array<Physical>, nearObjects: Array<Physical>,
): Array<{ planet: PlanetPhysical; distance: number }> { ): Array<PlanetPhysical> {
const planets = nearObjects return planetsIn(nearObjects)
.filter( .filter((p) => p.team !== this.team)
(o): o is Physical => .sort(
o.gameObject instanceof PlanetPhysical && o.gameObject.team !== this.team, (a, b) =>
) vec2.distance(this.center, a.center) - vec2.distance(this.center, b.center),
.map((o) => ({ );
planet: o.gameObject as PlanetPhysical,
distance: vec2.distance(this.center, (o.gameObject as PlanetPhysical).center),
}));
planets.sort((a, b) => a.distance - b.distance);
return planets;
} }
} }

View file

@ -1,7 +1,6 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { import {
CommandReceiver, CommandReceiver,
Circle,
PlayerInformation, PlayerInformation,
CharacterTeam, CharacterTeam,
Random, Random,
@ -10,53 +9,64 @@ import {
boundRadius, boundRadius,
evaluateSdf, evaluateSdf,
} from 'shared'; } from 'shared';
import { PhysicalContainer } from '../physics/containers/physical-container'; import { PhysicalContainer } from '../physics/physical-container';
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle'; import { BoundingBox } from '../physics/bounding-box';
import { CharacterPhysical } from '../objects/character-physical'; import { CharacterPhysical } from '../objects/character-physical';
import { PlanetPhysical } from '../objects/planet-physical'; import { PlanetPhysical, planetsIn } from '../objects/planet-physical';
import { PlayerContainer } from './player-container'; import { PlayerContainer } from './player-container';
const maximumNameLength = 40; const maximumNameLength = 40;
export interface Score {
kills: number;
deaths: number;
}
export abstract class PlayerBase extends CommandReceiver { export abstract class PlayerBase extends CommandReceiver {
public character?: CharacterPhysical | null; public character: CharacterPhysical | null = null;
public center: vec2 = vec2.create(); public center: vec2 = vec2.create();
protected sumKills = 0; protected lastCharacter?: CharacterPhysical;
protected sumDeaths = 0;
protected timeUntilRespawn = 0; protected timeUntilRespawn = 0;
private kills: number;
private deaths: number;
constructor( constructor(
protected readonly playerInfo: PlayerInformation, protected readonly playerInfo: PlayerInformation,
protected readonly playerContainer: PlayerContainer, protected readonly playerContainer: PlayerContainer,
protected readonly objectContainer: PhysicalContainer, protected readonly objectContainer: PhysicalContainer,
public readonly team: CharacterTeam, public readonly team: CharacterTeam,
score: Score = { kills: 0, deaths: 0 },
) { ) {
super(); super();
this.kills = score.kills;
this.deaths = score.deaths;
} }
protected createCharacter() { public get score(): Score {
return {
kills: (this.character ?? this.lastCharacter)?.killCount ?? this.kills,
deaths: this.deaths,
};
}
protected createCharacter(): CharacterPhysical {
this.kills = this.lastCharacter?.killCount ?? this.kills;
this.character = new CharacterPhysical( this.character = new CharacterPhysical(
// Coerce, don't just truncate: input is untrusted (from deserialize), and
// arrays have `.slice()` too — a non-string name would throw inside the
// reviver and kill the recipient's whole message batch.
sanitizeName(this.playerInfo.name, maximumNameLength), sanitizeName(this.playerInfo.name, maximumNameLength),
this.sumKills, this.kills,
this.sumDeaths, this.deaths,
this.team, this.team,
this.objectContainer, this.objectContainer,
this.findEmptyPositionForPlayer(this.findSpawnCenter()), this.findEmptyPositionForPlayer(this.findSpawnCenter()),
); );
this.objectContainer.addObject(this.character); this.objectContainer.addObject(this.character);
this.center = this.character.center;
return this.character;
} }
public abstract step(deltaTimeInSeconds: number): void; public abstract step(deltaTimeInSeconds: number): void;
/**
* Shared death/respawn cycle. Returns the living character to act with this
* tick (or undefined), so a subclass can use it directly without re-narrowing.
*/
protected stepLifecycle(deltaTimeInSeconds: number): CharacterPhysical | undefined { protected stepLifecycle(deltaTimeInSeconds: number): CharacterPhysical | undefined {
if (this.character) { if (this.character) {
this.center = this.character.center; this.center = this.character.center;
@ -65,33 +75,25 @@ export abstract class PlayerBase extends CommandReceiver {
return this.character; return this.character;
} }
this.sumDeaths++; this.deaths++;
this.sumKills = this.character.killCount; this.lastCharacter = this.character;
this.onCharacterDied(this.character);
this.character = null; this.character = null;
this.timeUntilRespawn = settings.playerDiedTimeout; this.timeUntilRespawn = settings.playerDiedTimeout;
return undefined; return undefined;
} }
if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) { if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) {
this.onBeforeRespawn();
this.createCharacter(); this.createCharacter();
this.center = this.character!.center;
} }
return undefined; return undefined;
} }
// Override hooks (no-ops by default).
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected onCharacterDied(character: CharacterPhysical) {}
protected onBeforeRespawn() {}
private findSpawnCenter(): vec2 { private findSpawnCenter(): vec2 {
const planets = this.objectContainer const planets = planetsIn(
.findIntersecting( this.objectContainer.findIntersecting(
getBoundingBoxOfCircle(new Circle(vec2.create(), settings.worldRadius * 2)), BoundingBox.ofCircle(vec2.create(), settings.worldRadius * 2),
) ),
.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical); );
const friendly = planets.filter((p) => p.team === this.team); const friendly = planets.filter((p) => p.team === this.team);
const neutral = planets.filter((p) => p.team === CharacterTeam.neutral); const neutral = planets.filter((p) => p.team === CharacterTeam.neutral);
@ -109,42 +111,32 @@ export abstract class PlayerBase extends CommandReceiver {
); );
const safe = candidates.filter((p) => !isContested(p)); const safe = candidates.filter((p) => !isContested(p));
// candidates is non-empty here, so choose() always returns a planet.
return vec2.clone(Random.choose(safe.length ? safe : candidates)!.center); return vec2.clone(Random.choose(safe.length ? safe : candidates)!.center);
} }
public restoreScore(kills: number, deaths: number) { private findEmptyPositionForPlayer(preferredCenter: vec2): vec2 {
this.sumKills = kills;
this.sumDeaths = deaths;
}
protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 {
let rotation = 0; let rotation = 0;
let radius = 0; let radius = 0;
// preferredCenter is a planet centre, the single worst place to give up on:
// depenetrateCircle only runs four passes, and a body left that deep inside
// the rock registers a zero-distance hit every march and never moves again.
// So the roomiest point the spiral saw is kept as the fallback instead.
let roomiestPosition = vec2.clone(preferredCenter); let roomiestPosition = vec2.clone(preferredCenter);
let roomiestClearance = -Infinity; let roomiestClearance = -Infinity;
for (let attempt = 0; attempt < 512; attempt++) { for (let attempt = 0; attempt < 512; attempt++) {
const playerPosition = vec2.fromValues( const position = vec2.fromValues(
radius * Math.cos(rotation) + preferredCenter.x, radius * Math.cos(rotation) + preferredCenter[0],
radius * Math.sin(rotation) + preferredCenter.y, radius * Math.sin(rotation) + preferredCenter[1],
); );
const playerBoundingCircle = new Circle(playerPosition, boundRadius); const clearance = evaluateSdf(
position,
const playerBoundingBox = getBoundingBoxOfCircle(playerBoundingCircle); this.objectContainer.findIntersecting(
const possibleIntersectors = BoundingBox.ofCircle(position, boundRadius),
this.objectContainer.findIntersecting(playerBoundingBox); ),
const clearance = evaluateSdf(playerBoundingCircle.center, possibleIntersectors); );
if (clearance >= playerBoundingCircle.radius) { if (clearance >= boundRadius) {
return playerPosition; return position;
} }
if (clearance > roomiestClearance) { if (clearance > roomiestClearance) {
roomiestClearance = clearance; roomiestClearance = clearance;
roomiestPosition = playerPosition; roomiestPosition = position;
} }
rotation += Math.PI / 8; rotation += Math.PI / 8;

View file

@ -1,22 +1,25 @@
import { CharacterTeam, PlayerInformation, Random, settings, Command } from 'shared'; import {
CharacterTeam,
PlayerInformation,
Random,
settings,
Command,
GameObject,
PropertyUpdatesForObject,
} from 'shared';
import { Socket } from 'socket.io'; import { Socket } from 'socket.io';
import { PhysicalContainer } from '../physics/containers/physical-container'; import { PhysicalContainer } from '../physics/physical-container';
import { NPC } from './npc'; import { NPC } from './npc';
import { Player } from './player'; import { Player } from './player';
import { PlayerBase } from './player-base'; import { PlayerBase, Score } from './player-base';
import { ServerFullError } from './server-full-error';
// Team and score carried over from the connection a reconnecting client is export interface CarriedScore extends Score {
// replacing, so it resumes the match instead of starting from zero.
export interface CarriedScore {
team: CharacterTeam; team: CharacterTeam;
kills: number;
deaths: number;
} }
export class PlayerContainer { export class PlayerContainer {
private _players: Array<Player> = []; private _players: Array<Player> = [];
private _npcs: Array<NPC> = []; private npcs: Array<NPC> = [];
constructor( constructor(
private readonly objects: PhysicalContainer, private readonly objects: PhysicalContainer,
@ -26,14 +29,14 @@ export class PlayerContainer {
this.createNPCs(); this.createNPCs();
} }
public createNPCs() { private createNPCs() {
const newNpcCount = Math.min( const newNpcCount = Math.min(
this.playerMaxCount - this._players.length - this._npcs.length, this.playerMaxCount - this._players.length - this.npcs.length,
this.npcMaxCount - this._npcs.length, this.npcMaxCount - this.npcs.length,
); );
for (let i = 0; i < newNpcCount; i++) { for (let i = 0; i < newNpcCount; i++) {
const name = `🤖 ${Random.choose(settings.npcNames)}`; const name = `🤖 ${Random.choose(settings.npcNames)}`;
this._npcs.push( this.npcs.push(
new NPC({ name }, this, this.objects, this.getTeamOfNextPlayer(true)), new NPC({ name }, this, this.objects, this.getTeamOfNextPlayer(true)),
); );
} }
@ -44,31 +47,20 @@ export class PlayerContainer {
socket: Socket, socket: Socket,
carried?: CarriedScore, carried?: CarriedScore,
): Player { ): Player {
if (this._players.length >= this.playerMaxCount) {
throw new ServerFullError();
}
const team = carried ? carried.team : this.getTeamOfNextPlayer(); const team = carried ? carried.team : this.getTeamOfNextPlayer();
// Retire the bot before the player spawns: its body would otherwise push
// the spawn search away from the position it picked.
const npcToReplace = const npcToReplace =
this._npcs.find((n) => n.team === team) ?? this._npcs.find((n) => n.team !== team); this.npcs.find((n) => n.team === team) ?? this.npcs.find((n) => n.team !== team);
npcToReplace?.destroy(); npcToReplace?.destroy();
this._npcs = this._npcs.filter((n) => n !== npcToReplace); this.npcs = this.npcs.filter((n) => n !== npcToReplace);
const player = new Player(playerInfo, this, this.objects, team, socket); const player = new Player(playerInfo, this, this.objects, team, socket, carried);
this._players.push(player); this._players.push(player);
if (carried) {
player.restoreScore(carried.kills, carried.deaths);
}
return player; return player;
} }
public get players(): Array<PlayerBase> { public get players(): Array<PlayerBase> {
return [...this._players, ...this._npcs]; return [...this._players, ...this.npcs];
} }
public get count(): number { public get count(): number {
@ -79,20 +71,22 @@ export class PlayerContainer {
return this._players.length >= this.playerMaxCount; return this._players.length >= this.playerMaxCount;
} }
// Real connected players only — NPCs have no socket and are excluded.
public get connectedPlayerRttsMs(): Array<number> { public get connectedPlayerRttsMs(): Array<number> {
return this._players.map((p) => p.rttMs); return this._players.map((p) => p.rttMs);
} }
public step(deltaTimeInSeconds: number) { public step(deltaTimeInSeconds: number) {
// Iterate the backing arrays directly: this runs per 200 Hz substep and the
// `players` getter allocates a new array every call.
this._players.forEach((p) => p.step(deltaTimeInSeconds)); this._players.forEach((p) => p.step(deltaTimeInSeconds));
this._npcs.forEach((p) => p.step(deltaTimeInSeconds)); this.npcs.forEach((p) => p.step(deltaTimeInSeconds));
} }
public stepCommunication(deltaTimeInSeconds: number) { public stepCommunication(
this._players.forEach((p) => p.stepCommunications(deltaTimeInSeconds)); deltaTimeInSeconds: number,
propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined,
) {
this._players.forEach((p) =>
p.stepCommunications(deltaTimeInSeconds, propertyUpdatesOf),
);
} }
public endGame(winner: CharacterTeam) { public endGame(winner: CharacterTeam) {
@ -120,12 +114,8 @@ export class PlayerContainer {
} }
public deletePlayer(player: Player) { public deletePlayer(player: Player) {
const had = this._players.includes(player); if (this._players.includes(player)) {
this._players = this._players.filter((p) => p !== player); this._players = this._players.filter((p) => p !== player);
// Only refill bots if this player was actually ours: a stale socket from a
// previous round reports its disconnect against the new container, and
// topping up on that would over-fill the roster.
if (had) {
this.createNPCs(); this.createNPCs();
} }
} }

View file

@ -31,44 +31,42 @@ import {
isFiniteNumber, isFiniteNumber,
} from 'shared'; } from 'shared';
import { Socket } from 'socket.io'; import { Socket } from 'socket.io';
import { BoundingBox } from '../physics/bounding-boxes/bounding-box'; import { BoundingBox } from '../physics/bounding-box';
import { PhysicalContainer } from '../physics/containers/physical-container'; import { PhysicalContainer } from '../physics/physical-container';
import { PlayerContainer } from './player-container'; import { PlayerContainer } from './player-container';
import { PlayerBase } from './player-base'; import { PlayerBase, Score } from './player-base';
import { CharacterPhysical } from '../objects/character-physical'; import { CharacterPhysical } from '../objects/character-physical';
const pingIntervalSeconds = 1; const pingIntervalSeconds = 1;
const minimumAspectRatio = 0.2; const minimumAspectRatio = 0.2;
const maximumAspectRatio = 8; const maximumAspectRatio = 8;
const maximumBufferedBytes = settings.maxBufferedBytesPerClient; const sheddableWhenBackedUp: ReadonlyArray<string> = [
PropertyUpdatesForObjects.type,
UpdateMinimap.type,
InputAcknowledgement.type,
];
export class Player extends PlayerBase { export class Player extends PlayerBase {
private aspectRatio: number = 16 / 9; public reconnectToken = '';
public rttMs = 0;
private aspectRatio = 16 / 9;
private timeSinceLastMessage = 0; private timeSinceLastMessage = 0;
// A Set: the per-snapshot diff is membership-heavy, and insertion-order
// iteration keeps the streamed object order unchanged.
private objectsInViewArea = new Set<GameObject>(); private objectsInViewArea = new Set<GameObject>();
private commandsToBeSent: Array<Command> = [];
private winnerTeam?: CharacterTeam;
private lastInputClientTimeMs = 0; private lastInputClientTimeMs = 0;
private lastInputReceiptMs = 0; private lastInputReceiptMs = 0;
private lastLeapClientTimeMs = 0; private lastLeapClientTimeMs = 0;
public reconnectToken = '';
// Server-side latency stat, reported in GameServer's periodic log.
public rttMs = 0;
private timeSinceLastPing = 0; private timeSinceLastPing = 0;
private lastPingSentMs = 0; private lastPingSentMs = 0;
// Nonce of the ping awaiting a reply: prevents a client forging Pongs or
// answering a stale ping to corrupt the measured RTT.
private pendingPingNonce = 0; private pendingPingNonce = 0;
private nextPingNonce = 1; private nextPingNonce = 1;
// Input below is untrusted: every command arrives from `deserialize` // Input is untrusted: one non-finite value reaching the simulation leaves a character that never dies.
// (a bare JSON.parse, which accepts Infinity as `1e999`). A single
// non-finite value reaching the simulation is unrecoverable — it spreads
// through the shared SDF and leaves a character that never dies or respawns.
protected commandExecutors: CommandExecutors = { protected commandExecutors: CommandExecutors = {
[SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) => { [SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) => {
this.aspectRatio = finiteInRange( this.aspectRatio = finiteInRange(
@ -84,7 +82,7 @@ export class Player extends PlayerBase {
return; return;
} }
this.observeClientTime(c.clientTimeMs); this.observeClientTime(c.clientTimeMs);
this.character?.handleMovementAction(new MoveActionCommand(direction, 0)); this.character?.setMoveDirection(direction);
}, },
[PrimaryActionCommand.type]: (c: PrimaryActionCommand) => { [PrimaryActionCommand.type]: (c: PrimaryActionCommand) => {
const position = finiteVec2(c.position, settings.maxClientPositionMagnitude); const position = finiteVec2(c.position, settings.maxClientPositionMagnitude);
@ -98,48 +96,34 @@ export class Player extends PlayerBase {
if (!isFiniteNumber(c.clientTimeMs)) { if (!isFiniteNumber(c.clientTimeMs)) {
return; return;
} }
// Record receipt whether or not leap() accepts it: the predictor must
// stop replaying this leap either way.
this.lastLeapClientTimeMs = c.clientTimeMs; this.lastLeapClientTimeMs = c.clientTimeMs;
this.observeClientTime(c.clientTimeMs); this.observeClientTime(c.clientTimeMs);
this.character?.leap(); this.character?.leap();
}, },
// Advances the acknowledgement at the client's send rate regardless of
// whether input changed — otherwise a held key freezes the predictor's
// replay window.
[ClientHeartbeatCommand.type]: (c: ClientHeartbeatCommand) => [ClientHeartbeatCommand.type]: (c: ClientHeartbeatCommand) =>
this.observeClientTime(c.clientTimeMs), this.observeClientTime(c.clientTimeMs),
}; };
// Tracks both the newest applied client-clock instant and the server-clock
// instant it arrived. The predictor needs both: which inputs are already in
// the snapshot, and how stale that answer was (see InputAcknowledgement).
private observeClientTime(clientTimeMs: number) {
if (isFiniteNumber(clientTimeMs) && clientTimeMs > this.lastInputClientTimeMs) {
this.lastInputClientTimeMs = clientTimeMs;
this.lastInputReceiptMs = performance.now();
}
}
constructor( constructor(
playerInfo: PlayerInformation, playerInfo: PlayerInformation,
playerContainer: PlayerContainer, playerContainer: PlayerContainer,
objectContainer: PhysicalContainer, objectContainer: PhysicalContainer,
team: CharacterTeam, team: CharacterTeam,
private readonly socket: Socket, private readonly socket: Socket,
score?: Score,
) { ) {
super(playerInfo, playerContainer, objectContainer, team); super(playerInfo, playerContainer, objectContainer, team, score);
this.createCharacter(); this.createCharacter();
this.step(0);
// Only one ping is ever in flight, so RTT is simply now − send-time.
this.socket.on(TransportEvents.Pong, this.onPong); this.socket.on(TransportEvents.Pong, this.onPong);
} }
// Only the outstanding ping's nonce counts, once: a client cannot forge a private observeClientTime(clientTimeMs: number) {
// Pong or answer a stale ping to skew the stat. Sitting on the live ping to if (isFiniteNumber(clientTimeMs) && clientTimeMs > this.lastInputClientTimeMs) {
// inflate the measurement is bounded by the clamp, and the next ping retires this.lastInputClientTimeMs = clientTimeMs;
// the nonce, so any stall costs at most one ping interval. this.lastInputReceiptMs = performance.now();
}
}
private readonly onPong = (nonce: unknown) => { private readonly onPong = (nonce: unknown) => {
if (this.pendingPingNonce === 0 || nonce !== this.pendingPingNonce) { if (this.pendingPingNonce === 0 || nonce !== this.pendingPingNonce) {
return; return;
@ -151,86 +135,93 @@ export class Player extends PlayerBase {
); );
}; };
// The Pong listener outlives the round: the socket survives a restart, so a
// retired Player would stay reachable through it unless removed explicitly.
public detachFromSocket() { public detachFromSocket() {
this.socket.off(TransportEvents.Pong, this.onPong); this.socket.off(TransportEvents.Pong, this.onPong);
} }
protected createCharacter() { protected createCharacter(): CharacterPhysical {
super.createCharacter(); const character = super.createCharacter();
this.objectsInViewArea.add(character);
this.objectsInViewArea.add(this.character!); this.queueCommandSend(new CreatePlayerCommand(character));
this.queueCommandSend(new CreatePlayerCommand(this.character!)); return character;
} }
private winnerTeam?: CharacterTeam;
public onGameEnded(winnerTeam: CharacterTeam) { public onGameEnded(winnerTeam: CharacterTeam) {
this.winnerTeam = winnerTeam; this.winnerTeam = winnerTeam;
} }
// Retained so an in-flight shot can still credit a kill to this corpse:
// killCount is re-read at respawn, not snapshotted when the body died.
private dyingCharacter?: CharacterPhysical | null;
public step(deltaTimeInSeconds: number) { public step(deltaTimeInSeconds: number) {
this.stepLifecycle(deltaTimeInSeconds); this.stepLifecycle(deltaTimeInSeconds);
} }
protected onCharacterDied(character: CharacterPhysical) { public queueCommandSend(command: Command) {
this.dyingCharacter = character; this.commandsToBeSent.push(command);
} }
protected onBeforeRespawn() { public stepCommunications(
if (this.dyingCharacter) { deltaTime: number,
this.sumKills = Math.max(this.sumKills, this.dyingCharacter.killCount); propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined,
this.dyingCharacter = null; ) {
const remoteCalls: Array<RemoteCallsForObject> = [];
for (const object of this.objectsInViewArea) {
const calls = object.getRemoteCalls();
if (calls.length > 0) {
remoteCalls.push(new RemoteCallsForObject(object.id, calls));
}
}
if (remoteCalls.length > 0) {
this.queueCommandSend(new RemoteCallsForObjects(remoteCalls));
}
if ((this.timeSinceLastPing += deltaTime) > pingIntervalSeconds) {
this.timeSinceLastPing = 0;
this.lastPingSentMs = performance.now();
this.pendingPingNonce = this.nextPingNonce++;
this.socket.emit(TransportEvents.Ping, this.pendingPingNonce);
}
if ((this.timeSinceLastMessage += deltaTime) > settings.updateMessageInterval) {
this.timeSinceLastMessage = 0;
this.queueAnnouncement();
this.queueSnapshot(propertyUpdatesOf);
this.sendQueuedCommandsToClient();
} }
} }
private handleViewAreaUpdate() { private queueSnapshot(
const viewArea = calculateViewArea(this.center, this.aspectRatio, 1.2); propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined,
const bb = new BoundingBox(); ) {
bb.topLeft = viewArea.topLeft; const { topLeft, size } = calculateViewArea(this.center, this.aspectRatio, 1.2);
bb.size = viewArea.size; const viewBox = new BoundingBox(
topLeft[0],
const inViewArea = new Set( topLeft[0] + size[0],
this.objectContainer.findIntersecting(bb).map((o) => o.gameObject), topLeft[1] - size[1],
topLeft[1],
); );
// The owning character must always be in its own snapshot so the client const inViewArea = new Set(
// predictor never loses its authoritative anchor. this.objectContainer.findIntersecting(viewBox).map((o) => o.gameObject),
);
if (this.character) { if (this.character) {
inViewArea.add(this.character); inViewArea.add(this.character);
} }
// Set membership rather than Array.includes: this diff runs per player per const created = [...inViewArea].filter((o) => !this.objectsInViewArea.has(o));
// snapshot over everything on screen and was quadratic in that. const deleted = [...this.objectsInViewArea].filter((o) => !inViewArea.has(o));
const newlyIntersecting = [...inViewArea].filter(
(o) => !this.objectsInViewArea.has(o),
);
const noLongerIntersecting = [...this.objectsInViewArea].filter(
(o) => !inViewArea.has(o),
);
this.objectsInViewArea = inViewArea; this.objectsInViewArea = inViewArea;
if (noLongerIntersecting.length > 0) { if (deleted.length > 0) {
this.queueCommandSend( this.queueCommandSend(new DeleteObjectsCommand(deleted.map((g) => g.id)));
new DeleteObjectsCommand(noLongerIntersecting.map((g) => g.id)),
);
} }
if (created.length > 0) {
if (newlyIntersecting.length > 0) { this.queueCommandSend(new CreateObjectsCommand(created));
this.queueCommandSend(new CreateObjectsCommand(newlyIntersecting));
} }
this.queueCommandSend(new UpdateMinimap(this.getMinimapPlayers())); this.queueCommandSend(new UpdateMinimap(this.getMinimapPlayers()));
const propertyUpdates: Array<PropertyUpdatesForObject> = []; const propertyUpdates: Array<PropertyUpdatesForObject> = [];
for (const object of this.objectsInViewArea) { for (const object of this.objectsInViewArea) {
const update = object.getPropertyUpdatesForFrame(); const update = propertyUpdatesOf(object);
if (update) { if (update) {
propertyUpdates.push(update); propertyUpdates.push(update);
} }
@ -239,8 +230,6 @@ export class Player extends PlayerBase {
new PropertyUpdatesForObjects(propertyUpdates, performance.now() / 1000), new PropertyUpdatesForObjects(propertyUpdates, performance.now() / 1000),
); );
// Tells the client how much of its own input is reflected in the snapshot
// so its predictor can replay the rest. Only while alive.
if (this.character) { if (this.character) {
this.queueCommandSend( this.queueCommandSend(
new InputAcknowledgement( new InputAcknowledgement(
@ -255,8 +244,6 @@ export class Player extends PlayerBase {
} }
} }
// Every living player except this one, by absolute world position for the
// minimap.
private getMinimapPlayers(): Array<MinimapPlayer> { private getMinimapPlayers(): Array<MinimapPlayer> {
return this.playerContainer.players return this.playerContainer.players
.filter((p) => p !== this && p.character?.isAlive) .filter((p) => p !== this && p.character?.isAlive)
@ -266,42 +253,6 @@ export class Player extends PlayerBase {
); );
} }
private commandsToBeSent: Array<Command> = [];
public queueCommandSend(command: Command) {
this.commandsToBeSent.push(command);
}
public stepCommunications(deltaTime: number) {
// Test first and only build the command when there is something to send.
let remoteCalls: Array<RemoteCallsForObject> | undefined;
for (const object of this.objectsInViewArea) {
const calls = object.getRemoteCalls();
if (calls.length > 0) {
(remoteCalls ??= []).push(new RemoteCallsForObject(object.id, calls));
}
}
if (remoteCalls) {
this.queueCommandSend(new RemoteCallsForObjects(remoteCalls));
}
if ((this.timeSinceLastPing += deltaTime) > pingIntervalSeconds) {
this.timeSinceLastPing = 0;
this.lastPingSentMs = performance.now();
this.pendingPingNonce = this.nextPingNonce++;
this.socket.emit(TransportEvents.Ping, this.pendingPingNonce);
}
if ((this.timeSinceLastMessage += deltaTime) > settings.updateMessageInterval) {
this.handleAnnouncements();
this.handleViewAreaUpdate();
this.sendQueuedCommandsToClient();
this.timeSinceLastMessage = 0;
}
}
// engine.io's Socket exposes no bufferedAmount — it lives on the underlying
// ws socket, and only the websocket transport has one.
private get bufferedBytes(): number { private get bufferedBytes(): number {
const conn = this.socket.conn as const conn = this.socket.conn as
| { transport?: { socket?: { bufferedAmount?: unknown } } } | { transport?: { socket?: { bufferedAmount?: unknown } } }
@ -311,53 +262,31 @@ export class Player extends PlayerBase {
} }
public sendQueuedCommandsToClient() { public sendQueuedCommandsToClient() {
// Only state the next snapshot regenerates may be dropped; create/delete are one-shot.
if (this.bufferedBytes > settings.maxBufferedBytesPerClient) {
this.commandsToBeSent = this.commandsToBeSent.filter(
(c) => !sheddableWhenBackedUp.includes(c.type),
);
}
if (this.commandsToBeSent.length === 0) { if (this.commandsToBeSent.length === 0) {
return; return;
} }
// Only state next tick regenerates in full may be dropped. Create/delete
// are one-shot: handleViewAreaUpdate has ALREADY advanced
// objectsPreviouslyInViewArea, so dropping them leaves this client
// permanently missing objects and holding ghosts. They go out even backed up.
if (this.bufferedBytes > maximumBufferedBytes) {
// Matched by command type, not `instanceof`: a class identity from a
// bundled shared module is not something to depend on.
const sheddable: ReadonlyArray<string> = [
PropertyUpdatesForObjects.type,
UpdateMinimap.type,
// Regenerated in full next tick — shed with the pose, never advance the
// replay anchor past a snapshot the client never received.
InputAcknowledgement.type,
];
this.commandsToBeSent = this.commandsToBeSent.filter(
(c) => !sheddable.includes(c.type),
);
if (this.commandsToBeSent.length === 0) {
return;
}
}
this.socket.emit(TransportEvents.ServerToPlayer, serialize(this.commandsToBeSent)); this.socket.emit(TransportEvents.ServerToPlayer, serialize(this.commandsToBeSent));
this.commandsToBeSent = []; this.commandsToBeSent = [];
} }
public get scoreSnapshot(): { kills: number; deaths: number } { private queueAnnouncement() {
return {
kills: Math.max(this.sumKills, this.character?.killCount ?? 0),
deaths: this.sumDeaths,
};
}
private handleAnnouncements() {
let announcement = '';
if (this.winnerTeam) { if (this.winnerTeam) {
announcement = `Team <span class="${this.winnerTeam}">${this.winnerTeam}</span> won 🎉`; this.queueCommandSend(
new ServerAnnouncement(
`Team <span class="${this.winnerTeam}">${this.winnerTeam}</span> won 🎉`,
),
);
} else if (!this.character) { } else if (!this.character) {
announcement = `Reviving in ${Math.round(this.timeUntilRespawn)}…`; this.queueCommandSend(
} new ServerAnnouncement(`Reviving in ${Math.round(this.timeUntilRespawn)}…`),
);
if (announcement) {
this.queueCommandSend(new ServerAnnouncement(announcement));
} }
} }
} }

View file

@ -33,8 +33,7 @@ module.exports = (env, argv) => ({
minimizer: [ minimizer: [
new TerserJSPlugin({ new TerserJSPlugin({
exclude: /node_modules/, exclude: /node_modules/,
// The custom serialization protocol keys on class names, so they must // The serialization protocol keys on class names.
// survive minification (see shared/src/serialization).
terserOptions: { terserOptions: {
keep_classnames: true, keep_classnames: true,
}, },

View file

@ -1,4 +1,3 @@
// Flat config (ESLint 9+/10). Ported from the former .eslintrc.json.
const tseslint = require('typescript-eslint'); const tseslint = require('typescript-eslint');
const prettierRecommended = require('eslint-plugin-prettier/recommended'); const prettierRecommended = require('eslint-plugin-prettier/recommended');
const unusedImports = require('eslint-plugin-unused-imports'); const unusedImports = require('eslint-plugin-unused-imports');
@ -25,7 +24,6 @@ module.exports = tseslint.config(
'@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-non-null-assertion': 'off', '@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-empty-function': 'off', '@typescript-eslint/no-empty-function': 'off',
// Renamed from the former no-var-requires; keep require() allowed.
'@typescript-eslint/no-require-imports': 'off', '@typescript-eslint/no-require-imports': 'off',
}, },
}, },

View file

@ -1,6 +1,5 @@
import { glMatrix } from 'gl-matrix'; import { glMatrix } from 'gl-matrix';
import { import {
applyArrayPlugins,
LampBase, LampBase,
overrideDeserialization, overrideDeserialization,
PlanetBase, PlanetBase,
@ -14,20 +13,12 @@ import '../static/favicons/apple-touch-icon.png';
import '../static/favicons/favicon-16x16.png'; import '../static/favicons/favicon-16x16.png';
import '../static/favicons/favicon-32x32.png'; import '../static/favicons/favicon-32x32.png';
import '../static/favicons/favicon.ico'; import '../static/favicons/favicon.ico';
import ResizeObserver from 'resize-observer-polyfill';
import { LandingPageBackground } from './scripts/landing-page-background'; import { LandingPageBackground } from './scripts/landing-page-background';
import { JoinFormHandler } from './scripts/join-form-handler'; import { JoinFormHandler } from './scripts/join-form-handler';
import { handleFullScreen } from './scripts/helper/handle-full-screen'; import { handleFullScreen } from './scripts/helper/handle-full-screen';
import { Game } from './scripts/game'; import { Game } from './scripts/game';
import ResizeObserver from 'resize-observer-polyfill'; import { initializeOptions } from './scripts/options-handler';
import { OptionsHandler } from './scripts/options-handler';
const hide = (e: HTMLElement, d?: boolean) => {
if (d) e.style.display = 'none';
else e.style.visibility = 'hidden';
};
const show = (e: HTMLElement, d?: boolean, v?: string) => {
if (d) e.style.display = v!;
else e.style.visibility = 'inherit';
};
import { SoundHandler, Sounds } from './scripts/sound-handler'; import { SoundHandler, Sounds } from './scripts/sound-handler';
import { VibrationHandler } from './scripts/vibration-handler'; import { VibrationHandler } from './scripts/vibration-handler';
import { CharacterView } from './scripts/objects/types/character-view'; import { CharacterView } from './scripts/objects/types/character-view';
@ -36,36 +27,43 @@ import { PlanetView } from './scripts/objects/types/planet-view';
import { ProjectileView } from './scripts/objects/types/projectile-view'; import { ProjectileView } from './scripts/objects/types/projectile-view';
glMatrix.setMatrixArrayType(Array); glMatrix.setMatrixArrayType(Array);
applyArrayPlugins();
overrideDeserialization(CharacterBase, CharacterView); overrideDeserialization(CharacterBase, CharacterView);
overrideDeserialization(PlanetBase, PlanetView); overrideDeserialization(PlanetBase, PlanetView);
overrideDeserialization(LampBase, LampView); overrideDeserialization(LampBase, LampView);
overrideDeserialization(ProjectileBase, ProjectileView); overrideDeserialization(ProjectileBase, ProjectileView);
const landingUI = document.querySelector('#landing-ui') as HTMLElement; const query = <T extends HTMLElement>(selector: string) =>
const nameInput = document.querySelector('#name') as HTMLInputElement; document.querySelector(selector) as T;
const joinGameForm = document.querySelector('#join-game-form') as HTMLFormElement;
const serverContainer = document.querySelector('#server-container') as HTMLElement; const landingUI = query('#landing-ui');
const canvas = document.querySelector('canvas') as HTMLCanvasElement; const nameInput = query<HTMLInputElement>('#name');
const overlay = document.querySelector('#overlay') as HTMLElement; const joinGameForm = query<HTMLFormElement>('#join-game-form');
const settings = document.querySelector('#settings') as HTMLElement; const serverContainer = query('#server-container');
const toggleSettingsButton = document.querySelector( const canvas = query<HTMLCanvasElement>('canvas');
'#toggle-settings-container', const overlay = query('#overlay');
) as HTMLElement; const settings = query('#settings');
const minimize = document.querySelector('#minimize') as HTMLElement; const toggleSettingsButton = query('#toggle-settings-container');
const maximize = document.querySelector('#maximize') as HTMLElement; const minimize = query('#minimize');
const logoutButton = document.querySelector('#logout') as HTMLElement; const maximize = query('#maximize');
const enableSounds = document.querySelector('#enable-sounds') as HTMLInputElement; const logoutButton = query('#logout');
const enableMusic = document.querySelector('#enable-music') as HTMLInputElement; const enableSounds = query<HTMLInputElement>('#enable-sounds');
const enableVibration = document.querySelector('#enable-vibration') as HTMLInputElement; const enableMusic = query<HTMLInputElement>('#enable-music');
const spinner = document.querySelector('#spinner-container') as HTMLElement; const enableVibration = query<HTMLInputElement>('#enable-vibration');
const spinner = query('#spinner-container');
const joinNotice = document.createElement('p'); const joinNotice = document.createElement('p');
joinNotice.className = 'join-notice'; joinNotice.className = 'join-notice';
joinNotice.style.display = 'none'; joinNotice.style.display = 'none';
joinGameForm.prepend(joinNotice); joinGameForm.prepend(joinNotice);
const setVisible = (e: HTMLElement, visible: boolean) => {
e.style.visibility = visible ? 'inherit' : 'hidden';
};
const setDisplayed = (e: HTMLElement, display: string | null) => {
e.style.display = display ?? 'none';
};
const toggleSettings = () => { const toggleSettings = () => {
settings.className = settings.className === 'open' ? '' : 'open'; settings.className = settings.className === 'open' ? '' : 'open';
SoundHandler.play(Sounds.click); SoundHandler.play(Sounds.click);
@ -76,21 +74,19 @@ const applyServerContainerShadows = () => {
const bottomShadow = 'inset 0 8px 8px -8px rgba(0, 0, 0, 0.4)'; const bottomShadow = 'inset 0 8px 8px -8px rgba(0, 0, 0, 0.4)';
const { scrollHeight, clientHeight, scrollTop } = serverContainer; const { scrollHeight, clientHeight, scrollTop } = serverContainer;
if (scrollHeight > clientHeight) { if (scrollHeight <= clientHeight) {
if (scrollTop <= 0) {
serverContainer.style.boxShadow = topShadow;
} else if (scrollTop + clientHeight >= scrollHeight) {
serverContainer.style.boxShadow = bottomShadow;
} else {
serverContainer.style.boxShadow = topShadow + ',' + bottomShadow;
}
} else {
serverContainer.style.boxShadow = ''; serverContainer.style.boxShadow = '';
} else if (scrollTop <= 0) {
serverContainer.style.boxShadow = topShadow;
} else if (scrollTop + clientHeight >= scrollHeight) {
serverContainer.style.boxShadow = bottomShadow;
} else {
serverContainer.style.boxShadow = topShadow + ',' + bottomShadow;
} }
}; };
const main = async () => { const main = async () => {
let game: Game; let game: Game | undefined;
const storedUserName = localStorage.getItem('userName'); const storedUserName = localStorage.getItem('userName');
if (storedUserName) { if (storedUserName) {
@ -113,7 +109,7 @@ const main = async () => {
document.addEventListener('click', firstClickListener); document.addEventListener('click', firstClickListener);
if (!VibrationHandler.isVibrationEnabledHeuristics) { if (!VibrationHandler.isVibrationEnabledHeuristics) {
hide(document.querySelector("label[for='enable-vibration']") as HTMLElement, true); setDisplayed(query("label[for='enable-vibration']"), null);
} }
handleFullScreen(minimize, maximize); handleFullScreen(minimize, maximize);
@ -122,28 +118,28 @@ const main = async () => {
new ResizeObserver(applyServerContainerShadows).observe(serverContainer); new ResizeObserver(applyServerContainerShadows).observe(serverContainer);
serverContainer.addEventListener('scroll', applyServerContainerShadows); serverContainer.addEventListener('scroll', applyServerContainerShadows);
OptionsHandler.initialize({ initializeOptions({
soundsEnabled: enableSounds, soundsEnabled: enableSounds,
vibrationEnabled: enableVibration, vibrationEnabled: enableVibration,
musicEnabled: enableMusic, musicEnabled: enableMusic,
}); });
logoutButton.addEventListener('click', () => { logoutButton.addEventListener('click', () => {
game.destroy(); game?.destroy();
toggleSettings(); toggleSettings();
}); });
window.onpopstate = () => game.destroy(); window.onpopstate = () => game?.destroy();
for (;;) { for (;;) {
show(spinner); setVisible(spinner, true);
hide(logoutButton, true); setDisplayed(logoutButton, null);
show(landingUI, true, 'flex'); setDisplayed(landingUI, 'flex');
const background = new LandingPageBackground(canvas); const background = new LandingPageBackground(canvas);
const joinHandler = new JoinFormHandler(joinGameForm, serverContainer); const joinHandler = new JoinFormHandler(joinGameForm, serverContainer);
await background.renderer; await background.renderer;
hide(spinner); setVisible(spinner, false);
const playerDecision = await joinHandler.getPlayerDecision(); const playerDecision = await joinHandler.getPlayerDecision();
@ -153,29 +149,25 @@ const main = async () => {
history.pushState(true, ''); history.pushState(true, '');
} }
hide(landingUI, true); setDisplayed(landingUI, null);
show(spinner); setVisible(spinner, true);
background.destroy(); background.destroy();
game = new Game(playerDecision, canvas, overlay); game = new Game(playerDecision, canvas, overlay);
const gameOver = game.start(); const gameOver = game.start();
await game.started; await game.started;
hide(spinner); setVisible(spinner, false);
show(logoutButton, true, 'block'); setDisplayed(logoutButton, 'block');
await gameOver; await gameOver;
const reason = game.lastRejectionReason; const reason = game.rejectionReason;
if (reason) { joinNotice.innerText = reason ? Game.rejectionText(reason) : '';
joinNotice.innerText = Game.rejectionText(reason); setDisplayed(joinNotice, reason ? 'block' : null);
joinNotice.style.display = 'block';
} else {
joinNotice.style.display = 'none';
}
} }
}; };
main().catch((error) => { main().catch((error) => {
console.error(error); console.error(error);
hide(spinner); setVisible(spinner, false);
joinNotice.innerText = 'Something went wrong. Please reload the page.'; joinNotice.innerText = 'Something went wrong. Please reload the page.';
joinNotice.style.display = 'block'; setDisplayed(joinNotice, 'block');
}); });

View file

@ -1,4 +1,4 @@
import { holdDurationToCharge } from 'shared'; import { chargeHeldSince } from 'shared';
import { pointer } from './helper/pointer'; import { pointer } from './helper/pointer';
let element: HTMLElement | undefined; let element: HTMLElement | undefined;
@ -24,7 +24,7 @@ function beginChargeIndicator(x: number, y: number, followPointer = false) {
function endChargeIndicator() { function endChargeIndicator() {
if (element) { if (element) {
cancelAnimationFrame(raf); cancelAnimationFrame(raf);
element.parentElement?.removeChild(element); element.remove();
element = undefined; element = undefined;
} }
} }
@ -34,7 +34,7 @@ function updateChargeIndicator() {
return; return;
} }
const charge = holdDurationToCharge((performance.now() - heldSince) / 1000); const charge = chargeHeldSince(heldSince);
element.style.opacity = charge < 0.12 ? '0' : '1'; element.style.opacity = charge < 0.12 ? '0' : '1';
element.style.background = `conic-gradient(rgba(255, 255, 255, 0.85) ${ element.style.background = `conic-gradient(rgba(255, 255, 255, 0.85) ${
charge * 360 charge * 360
@ -44,15 +44,14 @@ function updateChargeIndicator() {
if (isFollowingPointer) { if (isFollowingPointer) {
const cursor = pointer.displayPosition; const cursor = pointer.displayPosition;
if (cursor) { if (cursor) {
element.style.left = `${cursor.x}px`; element.style.left = `${cursor[0]}px`;
element.style.top = `${cursor.y}px`; element.style.top = `${cursor[1]}px`;
} }
} }
raf = requestAnimationFrame(updateChargeIndicator); raf = requestAnimationFrame(updateChargeIndicator);
} }
// The one public surface; the functions above stay module-private.
export const ChargeIndicator = { export const ChargeIndicator = {
begin: beginChargeIndicator, begin: beginChargeIndicator,
end: endChargeIndicator, end: endChargeIndicator,

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