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

View file

@ -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 { randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks';
@ -13,39 +12,33 @@ import {
GameEndCommand,
GameStartCommand,
Command,
CommandReceiver,
CommandExecutors,
ServerAnnouncement,
JoinRejectionReason,
beginPropertyUpdateGeneration,
GameObject,
PropertyUpdatesForObject,
} from 'shared';
import { PhysicalContainer } from './physics/physical-container';
import { createWorld } from './create-world';
import { Options } from './options';
import { GameEvents } from './game-events';
import { CarriedScore, PlayerContainer } from './players/player-container';
import { ServerFullError } from './players/server-full-error';
import { Player } from './players/player';
import { StepCommand, GeneratePointsCommand, AnnounceCommand } from './commands/commands';
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 => {
if (!payload || typeof payload !== 'object') {
return undefined;
}
const { name, reconnectToken } = payload as Record<string, unknown>;
return {
// Coerced and length-capped downstream, in PlayerBase.createCharacter.
name: name as string,
reconnectToken: typeof reconnectToken === 'string' ? reconnectToken : undefined,
};
};
export class GameServer extends CommandReceiver {
export class GameServer implements GameEvents {
private objects!: PhysicalContainer;
private players!: PlayerContainer;
private lastPhysicsBase = process.hrtime.bigint();
@ -57,45 +50,24 @@ export class GameServer extends CommandReceiver {
private isInEndGame = false;
private timeScaling = 1;
private statReportMs = Date.now();
private timeSinceLastServerStateUpdate = 0;
private timeSinceLastPointUpdate = 0;
private physicsAccumulator = 0;
private saturatedFrames = 0;
private droppedInboundMessages = 0;
private initialize() {
const previousPlayers = this.players;
this.releaseJoinedSockets();
this.objects = new PhysicalContainer();
createWorld(this.objects);
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)),
};
private readonly joinedSockets = new Map<
Socket,
{ player: Player; release: () => void }
>();
private inboundBudget = new WeakMap<Socket, { tokens: number; lastMs: number }>();
constructor(
private readonly io: Server,
private options: Options,
) {
super();
this.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) => {
socket.on(TransportEvents.PlayerJoining, (payload: unknown) => {
if (!this.allowInboundMessage(socket)) {
@ -111,18 +83,35 @@ export class GameServer extends CommandReceiver {
});
socket.on(TransportEvents.SubscribeForServerInfoUpdates, () => {
if (!this.allowInboundMessage(socket)) {
return;
if (this.allowInboundMessage(socket)) {
socket.join(gameStateSubscribedRoom);
}
socket.join(gameStateSubscribedRoom);
});
});
}
private readonly joinedSockets = new Map<
Socket,
{ player: Player; release: () => void }
>();
private initialize() {
const previousPlayers = this.players;
this.joinedSockets.forEach(({ release }) => release());
this.joinedSockets.clear();
this.inboundBudget = new WeakMap();
this.objects = new PhysicalContainer(this);
createWorld(this.objects);
this.players = new PlayerContainer(
this.objects,
this.options.playerLimit,
this.options.npcCount,
);
this.lastPhysicsBase = process.hrtime.bigint();
this.bluePoints = 0;
this.redPoints = 0;
this.matchPointAnnounced = {};
this.isInEndGame = false;
this.timeScaling = 1;
previousPlayers?.queueCommandForEachClient(new GameStartCommand());
previousPlayers?.sendQueuedCommands();
}
private handleJoin(socket: Socket, payload: unknown) {
const playerInfo = asPlayerInformation(payload);
@ -141,9 +130,6 @@ export class GameServer extends CommandReceiver {
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);
if (this.players.isFull) {
@ -151,20 +137,7 @@ export class GameServer extends CommandReceiver {
return;
}
let player: Player;
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 player = this.players.createPlayer(playerInfo, socket, carried);
const onPlayerToServer = (json: string) => {
try {
@ -203,14 +176,8 @@ export class GameServer extends CommandReceiver {
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 {
// performance.now(), never Date.now(): a backwards wall-clock step (an NTP
// correction, a VM resync) would make the refill negative and lock the
// client out of sending input for as long as the step was.
// performance.now(), not Date.now(): a backwards wall-clock step would lock the client out.
const nowMs = performance.now();
const burst = settings.maxInboundMessageBurst;
const perSecond = settings.maxInboundMessagesPerSecond;
@ -238,7 +205,7 @@ export class GameServer extends CommandReceiver {
}
for (const [socket, { player }] of this.joinedSockets) {
if (player.reconnectToken === token) {
const carried = { team: player.team, ...player.scoreSnapshot };
const carried = { team: player.team, ...player.score };
this.removeJoined(socket);
return carried;
}
@ -253,26 +220,17 @@ export class GameServer extends CommandReceiver {
}
this.joinedSockets.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.player.destroy();
this.players.deletePlayer(joined.player);
}
private releaseJoinedSockets() {
this.joinedSockets.forEach(({ release }) => release());
this.joinedSockets.clear();
this.inboundBudget = new WeakMap();
}
private timeSinceLastServerStateUpdate = 0;
public sendServerStateUpdate() {
this.io
.to(gameStateSubscribedRoom)
.emit(TransportEvents.ServerInfoUpdate, [
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();
}
private addPoints({ blue, red }: GeneratePointsCommand) {
public addPoints(blue: number, red: number) {
if (this.isInEndGame) {
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) {
if (
!this.matchPointAnnounced[team] &&
points >= this.options.scoreLimit * settings.matchPointScoreRatio
) {
this.matchPointAnnounced[team] = true;
this.players.queueCommandForEachClient(
new ServerAnnouncement(
`Match point — team <span class="${team}">${team}</span>!`,
),
);
this.announce(`Match point — team <span class="${team}">${team}</span>!`);
}
}
private endGame(winningTeam: CharacterTeam) {
this.isInEndGame = true;
const endTitleLength = 6;
this.players.endGame(winningTeam);
this.players.queueCommandForEachClient(new GameEndCommand());
setTimeout(() => this.initialize(), endTitleLength * 1000 * 1.1);
setTimeout(() => this.initialize(), endTitleSeconds * 1000 * 1.1);
}
private timeSinceLastPointUpdate = 0;
private physicsAccumulator = 0;
private saturatedFrames = 0;
private reportStats() {
const mem = `${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`;
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() {
const frameStart = process.hrtime.bigint();
@ -330,26 +306,7 @@ export class GameServer extends CommandReceiver {
if (Date.now() - this.statReportMs > 30000) {
this.statReportMs = Date.now();
const mem = `${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`;
console.info(`Memory: ${mem}, Players: ${this.players.count}`);
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;
}
this.reportStats();
}
if ((this.timeSinceLastServerStateUpdate += delta) > 4) {
@ -387,17 +344,21 @@ export class GameServer extends CommandReceiver {
this.timeScaling *= Math.pow(settings.endGameDeltaScaling, fixedDelta);
scaledDelta /= this.timeScaling;
}
this.objects.handleCommand(new StepCommand(scaledDelta, this));
this.objects.step(scaledDelta);
this.players.step(scaledDelta);
}
beginPropertyUpdateGeneration();
this.players.stepCommunication(delta);
const propertyUpdates = new Map<GameObject, PropertyUpdatesForObject | undefined>();
this.players.stepCommunication(delta, (object) => {
if (!propertyUpdates.has(object)) {
propertyUpdates.set(object, object.getPropertyUpdates());
}
return propertyUpdates.get(object);
});
this.objects.resetRemoteCalls();
const elapsed = Number(process.hrtime.bigint() - frameStart) / 1e9;
setTimeout(this.handlePhysics.bind(this), Math.max(0, fixedDelta - elapsed) * 1000);
setTimeout(() => this.handlePhysics(), Math.max(0, fixedDelta - elapsed) * 1000);
}
public get serverInfo(): ServerInformation {

View file

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

View file

@ -2,16 +2,13 @@ import { vec2 } from 'gl-matrix';
import {
id,
settings,
MoveActionCommand,
serializesTo,
Circle,
CharacterBase,
CharacterTeam,
PropertyUpdatesForObject,
UpdatePropertyCommand,
CommandExecutors,
CommandReceiver,
CharacterMovementSnapshot,
GameObject,
mix,
clamp01,
strengthToCharge,
@ -20,6 +17,7 @@ import {
decayMomentum,
tickPlanetDetachment,
characterCenter,
sumGravity,
CharacterWorld,
GroundSurface,
headRadius,
@ -29,69 +27,45 @@ import {
rightFootOffset,
boundRadius,
} from 'shared';
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
import { Physical } from '../physics/physical';
import { CirclePhysical } from './circle-physical';
import { PhysicalContainer } from '../physics/containers/physical-container';
import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base';
import { PhysicalContainer } from '../physics/physical-container';
import { BoundingBox } from '../physics/bounding-box';
import { ProjectilePhysical } from './projectile-physical';
import { forceAtPosition } from '../physics/functions/force-at-position';
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
import { PlanetPhysical } from './planet-physical';
import {
StepCommand,
ReactToCollisionCommand,
GeneratePointsCommand,
} from '../commands/commands';
import { PlanetPhysical, planetsIn } from './planet-physical';
interface BodyPose {
head: Circle;
leftFoot: Circle;
rightFoot: Circle;
}
const placeholder = new Circle(vec2.create(), 0);
@serializesTo(CharacterBase)
export class CharacterPhysical extends CharacterBase implements DynamicPhysical {
export class CharacterPhysical extends CharacterBase implements Physical {
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 leftFoot: CirclePhysical;
public rightFoot: CirclePhysical;
private movementActions: Array<MoveActionCommand> = [];
private lastMovementAction: MoveActionCommand = new MoveActionCommand(vec2.create());
public direction = 0;
public currentPlanet: GroundSurface | undefined;
public secondsSinceOnSurface = settings.planetDetachmentSeconds;
public readonly bodyVelocity = vec2.create();
private headVelocity = new Circle(vec2.create(), 0);
private leftFootVelocity = new Circle(vec2.create(), 0);
private rightFootVelocity = new Circle(vec2.create(), 0);
private projectileStrength = settings.playerMaxStrength;
private timeAlive = 0;
private timeSinceDying = 0;
private isDestroyed = false;
private hasJustBorn = true;
private hasFiredSinceSpawn = false;
private timeSinceLastShot = settings.projectileCreationInterval;
private timeSinceLastLeap = settings.leapCooldownSeconds;
private timeSinceLastDamage = settings.playerOutOfCombatDelaySeconds;
private lastSyncedHealth = settings.playerMaxHealth;
private killStreak = 0;
protected commandExecutors: CommandExecutors = {
[StepCommand.type]: this.step.bind(this),
[ReactToCollisionCommand.type]: this.onCollision.bind(this),
};
private movementDirections: Array<vec2> = [];
private lastMovementDirection = vec2.create();
private readonly previousPose = [0, 1, 2].map(() => new Circle(vec2.create(), 0));
private readonly velocities = [0, 1, 2].map(() => new Circle(vec2.create(), 0));
private readonly box = new BoundingBox();
constructor(
name: string,
@ -101,43 +75,42 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
private readonly container: PhysicalContainer,
startPosition: vec2,
) {
super(id(), name, killCount, deathCount, team, settings.playerMaxHealth);
this.head = new CirclePhysical(
vec2.add(vec2.create(), startPosition, headOffset),
headRadius,
this,
container,
super(
id(),
name,
killCount,
deathCount,
team,
settings.playerMaxHealth,
placeholder,
placeholder,
placeholder,
);
this.leftFoot = new CirclePhysical(
vec2.add(vec2.create(), startPosition, leftFootOffset),
feetRadius,
this,
container,
);
this.rightFoot = new CirclePhysical(
vec2.add(vec2.create(), startPosition, rightFootOffset),
feetRadius,
this,
container,
);
container.addObject(this.head);
container.addObject(this.leftFoot);
container.addObject(this.rightFoot);
const part = (offset: vec2, radius: number) =>
new CirclePhysical(
vec2.add(vec2.create(), startPosition, offset),
radius,
this,
container,
);
this.head = part(headOffset, headRadius);
this.leftFoot = part(leftFootOffset, feetRadius);
this.rightFoot = part(rightFootOffset, feetRadius);
}
private readonly movementWorld: CharacterWorld = {
// Same set and order forceAtPosition uses, so the f64 gravity sum matches.
groundsNear: (center, radius) =>
this.container
.findIntersecting(getBoundingBoxOfCircle(new Circle(center, radius)))
.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical),
planetsIn(this.container.findIntersecting(BoundingBox.ofCircle(center, radius))),
stepBody: (body, deltaTimeInSeconds) => {
const { hitObject } = (body as CirclePhysical).stepManually(deltaTimeInSeconds);
return hitObject instanceof PlanetPhysical ? hitObject : undefined;
const hit = (body as CirclePhysical).stepManually(deltaTimeInSeconds);
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 {
return (
!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 {
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 {
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 {
return new CharacterMovementSnapshot(
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) {
this.killCount++;
this.killStreak++;
@ -214,38 +194,38 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
}
}
public onCollision({ other }: ReactToCollisionCommand) {
public onCollision(other: GameObject) {
if (
// A corpse keeps its collidable circles for the despawn animation; the
// isAlive guard stops a flying corpse from eating shots aimed past it.
this.isAlive &&
other instanceof ProjectilePhysical &&
other.team !== this.team &&
other.isAlive
!this.isAlive ||
!(other instanceof ProjectilePhysical) ||
other.team === this.team ||
!other.isAlive
) {
other.destroy();
return;
}
if (this.isSpawnProtected) {
return;
}
other.destroy();
this.timeSinceLastDamage = 0;
this.health -= other.strength;
this.lastSyncedHealth = Math.round(this.health);
this.remoteCall('setHealth', this.health);
if (this.isSpawnProtected) {
return;
}
if (this.health <= 0 && this.isAlive) {
vec2.scaleAndAdd(
this.bodyVelocity,
this.bodyVelocity,
other.direction,
mix(settings.deathImpulseMin, settings.deathImpulseMax, other.charge),
);
this.onDie(true);
other.originator.addKill(this.name, other.charge);
} else {
other.originator.registerHit(other.charge);
}
this.timeSinceLastDamage = 0;
this.health -= other.strength;
this.lastSyncedHealth = Math.round(this.health);
this.remoteCall('setHealth', this.health);
if (this.health <= 0) {
vec2.scaleAndAdd(
this.bodyVelocity,
this.bodyVelocity,
other.direction,
mix(settings.deathImpulseMin, settings.deathImpulseMax, other.charge),
);
this.onDie(true);
other.originator.addKill(this.name, other.charge);
} else {
other.originator.registerHit(other.charge);
}
}
@ -266,41 +246,37 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
this.timeSinceLastShot = 0;
this.hasFiredSinceSpawn = true;
const requestedCharge = clamp01(charge);
const desiredStrength = mix(
settings.chargeShotStrengthMin,
settings.chargeShotStrengthMax,
requestedCharge,
clamp01(charge),
);
const strength = Math.min(desiredStrength, this.projectileStrength);
this.projectileStrength -= strength;
const c = strengthToCharge(strength);
const radius = mix(settings.chargeShotRadiusMin, settings.chargeShotRadiusMax, c);
const speed = mix(settings.chargeShotSpeedMin, settings.chargeShotSpeedMax, c);
vec2.normalize(direction, direction);
// Keep the unit direction before vec2.scale repurposes it as the velocity.
const shotDirection = vec2.clone(direction);
const velocity = vec2.scale(direction, direction, speed);
const projectile = new ProjectilePhysical(
vec2.clone(this.center),
radius,
strength,
this.team,
velocity,
this,
this.container,
c,
this.container.addObject(
new ProjectilePhysical(
vec2.clone(this.center),
radius,
strength,
this.team,
vec2.scale(vec2.create(), direction, speed),
this,
this.container,
c,
),
);
this.container.addObject(projectile);
if (c > 0) {
vec2.scaleAndAdd(
this.bodyVelocity,
this.bodyVelocity,
shotDirection,
direction,
-settings.chargeShotRecoilMax * c,
);
}
@ -321,67 +297,32 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
this.timeSinceLastLeap = 0;
this.projectileStrength -= settings.leapStrengthCost;
// Same impulse the client predicts with (shared), so a leap launches
// identically on both sides.
applyLeapImpulse(this, this.lastMovementAction.direction);
applyLeapImpulse(this, this.lastMovementDirection);
this.remoteCall('onLeap');
}
public get boundingBox(): BoundingBoxBase {
return getBoundingBoxOfCircle(new Circle(this.center, boundRadius));
}
public get gameObject(): this {
return this;
}
public get center(): vec2 {
return characterCenter(this.head, this.leftFoot, this.rightFoot);
}
public distance(target: vec2): number {
return (
Math.min(
this.head.distance(target),
this.leftFoot.distance(target),
this.rightFoot.distance(target),
) - 5
);
}
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 = [];
public onDie(killedInCombat = false) {
if (this.isDestroyed) {
return;
}
this.isDestroyed = true;
this.remoteCall('onDie');
return vec2.length(direction) > 0
? vec2.normalize(direction, direction)
: vec2.create();
}
private animateScaling(q: number) {
this.head.radius = headRadius * q;
this.leftFoot.radius = this.rightFoot.radius = feetRadius * q;
if (killedInCombat) {
const points = settings.playerKillPoint;
this.container.game.addPoints(
this.team === CharacterTeam.blue ? 0 : points,
this.team === CharacterTeam.red ? 0 : points,
);
}
}
public getPropertyUpdates(): PropertyUpdatesForObject {
const [headVelocity, leftFootVelocity, rightFootVelocity] = this.velocities;
return new PropertyUpdatesForObject(this.id, [
new UpdatePropertyCommand('head', this.head, this.headVelocity),
new UpdatePropertyCommand('leftFoot', this.leftFoot, this.leftFootVelocity),
new UpdatePropertyCommand('rightFoot', this.rightFoot, this.rightFootVelocity),
new UpdatePropertyCommand('head', this.head, headVelocity),
new UpdatePropertyCommand('leftFoot', this.leftFoot, leftFootVelocity),
new UpdatePropertyCommand('rightFoot', this.rightFoot, rightFootVelocity),
new UpdatePropertyCommand(
'strength',
this.projectileStrength,
@ -390,107 +331,51 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
]);
}
// `previous` is consumed as scratch so this allocates nothing beyond the
// Circle it returns.
private static rateOfChange(previous: Circle, current: Circle, deltaTime: number) {
return new Circle(
vec2.scale(
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);
public step(deltaTimeInSeconds: number) {
this.timeAlive += 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.timeSinceDying += deltaTimeInSeconds) > settings.spawnDespawnTime) {
this.destroy();
this.container.removeObject(this);
} else {
this.freeFallCorpse(deltaTimeInSeconds);
this.animateScaling(1 - this.timeSinceDying / settings.spawnDespawnTime);
}
this.setPropertyUpdates(previousPose, deltaTimeInSeconds);
return;
}
if (this.hasJustBorn) {
if ((this.timeSinceBorn += deltaTimeInSeconds) > settings.spawnDespawnTime) {
} else if (this.hasJustBorn) {
if (this.timeAlive > settings.spawnDespawnTime) {
this.hasJustBorn = false;
this.animateScaling(1);
} else {
this.animateScaling(this.timeSinceBorn / settings.spawnDespawnTime);
this.animateScaling(this.timeAlive / settings.spawnDespawnTime);
}
this.setPropertyUpdates(previousPose, deltaTimeInSeconds);
return;
} else {
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);
this.timeSinceLastShot += deltaTimeInSeconds;
this.projectileStrength = Math.min(
settings.playerMaxStrength,
this.projectileStrength +
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;
if (
this.timeSinceLastDamage > settings.playerOutOfCombatDelaySeconds &&
@ -502,18 +387,51 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
);
this.syncHealth();
}
this.groundPlanet?.registerPresence(this);
stepCharacterMovement(
this,
this.movementWorld,
this.averageAndResetMovementDirections(),
deltaTimeInSeconds,
);
}
public onDie(killedInCombat = false) {
this.wasKilledInCombat = killedInCombat;
this.isDestroyed = true;
this.remoteCall('onDie');
private averageAndResetMovementDirections(): vec2 {
const direction = vec2.create();
if (this.movementDirections.length === 0) {
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() {
this.container.removeObject(this);
this.container.removeObject(this.head);
this.container.removeObject(this.leftFoot);
this.container.removeObject(this.rightFoot);
private animateScaling(q: number) {
this.head.radius = headRadius * q;
this.leftFoot.radius = this.rightFoot.radius = feetRadius * q;
}
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 {
applyForce,
Circle,
CommandExecutors,
CommandReceiver,
GameObject,
resolveCircleMovement,
serializesTo,
} from 'shared';
import { BoundingBox } from '../physics/bounding-boxes/bounding-box';
import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base';
import { PhysicalContainer } from '../physics/containers/physical-container';
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
import { Physical } from '../physics/physicals/physical';
import { 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;
import { applyForce, Circle, GameObject, resolveCircleMovement } from 'shared';
import { BoundingBox } from '../physics/bounding-box';
import { PhysicalContainer } from '../physics/physical-container';
import { Physical } from '../physics/physical';
export class CirclePhysical extends Circle implements Physical {
public readonly canCollide = true;
public velocity = vec2.create();
public lastNormal = vec2.fromValues(0, 1);
private _boundingBox: BoundingBox;
protected commandExecutors: CommandExecutors = {
[ReactToCollisionCommand.type]: this.onCollision.bind(this),
};
private readonly box = new BoundingBox();
constructor(
private _center: vec2,
private _radius: number,
public owner: GameObject,
center: vec2,
radius: number,
public readonly owner: Physical,
private readonly container: PhysicalContainer,
// Public + readonly so CirclePhysical satisfies the shared PhysicsBody
// interface the movement simulation operates on.
public readonly restitution = 0,
) {
super();
this._boundingBox = new BoundingBox();
this.syncBoundingBox();
super(center, radius);
}
public get boundingBox(): BoundingBoxBase {
return this._boundingBox;
}
public get center(): vec2 {
return this._center;
}
public set center(value: vec2) {
this._center = value;
this.syncBoundingBox();
}
public onCollision(c: ReactToCollisionCommand) {
this.owner.handleCommand(c);
public get boundingBox(): 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;
this.box.yMax = this.center[1] + this.radius;
return this.box;
}
public get gameObject(): GameObject {
return this.owner;
return this.owner.gameObject;
}
public get radius(): number {
return this._radius;
}
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 onCollision(other: GameObject) {
this.owner.onCollision?.(other);
}
public applyForce(force: vec2, timeInSeconds: number) {
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(
deltaTimeInSeconds: number,
possibleIntersectors?: Array<Physical>,
): {
hitObject: GameObject | undefined;
velocity: vec2;
} {
): GameObject | undefined {
const intersecting = (
possibleIntersectors ?? this.sweptBroadphase(deltaTimeInSeconds)
).filter((b) => b.gameObject !== this.gameObject && b.canCollide);
const { hitObject, velocity } = resolveCircleMovement(
const hit = resolveCircleMovement(
this,
deltaTimeInSeconds,
intersecting,
(intersected) => {
const physical = intersected as Physical;
physical.handleCommand(new ReactToCollisionCommand(this.gameObject));
this.handleCommand(new ReactToCollisionCommand(physical.gameObject));
physical.onCollision?.(this.gameObject);
this.onCollision(physical.gameObject);
},
);
) as Physical | undefined;
this.syncBoundingBox();
return { hitObject: (hitObject as Physical | undefined)?.gameObject, velocity };
return hit?.gameObject;
}
private sweptBroadphase(deltaTimeInSeconds: number): Array<Physical> {
const sweep = vec2.length(
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.
const sweep = vec2.length(this.velocity) * deltaTimeInSeconds;
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 { 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';
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 {
export class LampPhysical extends LampBase implements Physical {
public readonly canCollide = false;
public readonly canMove = false;
public readonly boundingBox: BoundingBox;
constructor(center: vec2, color: vec3, lightness: number) {
super(id(), center, color, lightness);
}
private _boundingBox?: ImmutableBoundingBox;
public get boundingBox(): ImmutableBoundingBox {
if (!this._boundingBox) {
this._boundingBox = getBoundingBoxOfCircle(
new Circle(this.center, settings.lightCutoffDistance),
);
}
return this._boundingBox;
this.boundingBox = BoundingBox.ofCircle(center, settings.lightCutoffDistance);
}
public get gameObject(): this {

View file

@ -1,145 +1,90 @@
import { vec2 } from 'gl-matrix';
import {
Circle,
clamp01,
id,
mix,
Random,
serializesTo,
settings,
PlanetBase,
CharacterTeam,
PropertyUpdatesForObject,
UpdatePropertyCommand,
planetDistance,
planetGravity,
CommandExecutors,
CommandReceiver,
} from 'shared';
import {
GeneratePointsCommand,
AnnounceCommand,
StepCommand,
} from '../commands/commands';
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
import { StaticPhysical } from '../physics/physicals/static-physical';
import { GameEvents } from '../game-events';
import { BoundingBox } from '../physics/bounding-box';
import { Physical } from '../physics/physical';
import { LampPhysical } from './lamp-physical';
import type { CharacterPhysical } from './character-physical';
@serializesTo(PlanetBase)
export class PlanetPhysical extends PlanetBase implements StaticPhysical {
public readonly canCollide = true;
public readonly canMove = false;
// Marks this as standable ground for the shared movement simulation.
public readonly isGround = true;
export const planetsIn = (objects: Array<Physical>): Array<PlanetPhysical> =>
objects.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical);
export class PlanetPhysical extends PlanetBase implements Physical {
public readonly boundingBox: BoundingBox;
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 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 isContested = false;
private timeSinceLastPointGeneration = 0;
protected commandExecutors: CommandExecutors = {
[StepCommand.type]: this.step.bind(this),
};
public addLamp(lamp: LampPhysical) {
this.lamps.push(lamp);
}
constructor(vertices: Array<vec2>, isKeystone = false) {
constructor(
vertices: Array<vec2>,
isKeystone: boolean,
private readonly game: GameEvents,
) {
super(id(), vertices, 0.5, isKeystone);
const sizeClass = clamp01(
(this.radius - settings.planetMinReferenceRadius) /
(settings.planetMaxReferenceRadius - settings.planetMinReferenceRadius),
);
this.sizePointMultiplier = mix(1, settings.planetSizePointMultiplierMax, sizeClass);
this.rotationSpeed =
this.angularVelocity =
(0.05 + Random.getRandom() * 0.07) * (Random.getRandom() < 0.5 ? -1 : 1);
this.boundingBox = BoundingBox.ofCircle(
this.center,
Math.max(...vertices.map((v) => vec2.distance(this.center, v))),
);
}
public get gameObject(): this {
return this;
}
public addLamp(lamp: LampPhysical) {
this.lamps.push(lamp);
}
public registerPresence(character: CharacterPhysical) {
this.presentCharacters.push(character);
}
public distance(target: vec2): number {
// The shared planet outline — the same function the client predictor
// 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;
public step(deltaTimeInSeconds: number) {
this.advanceRotation(deltaTimeInSeconds);
this.timeSinceLastPointGeneration += deltaTimeInSeconds;
// In reverse order, so that teams can achieve a 100% control.
this.getPoints(game);
this.generatePoints();
this.resolveCapture(deltaTimeInSeconds);
this.detectFlip(game);
this.detectFlip();
this.presentCharacters = [];
this.presentCharacters.length = 0;
}
private generatePoints() {
if (this.timeSinceLastPointGeneration > settings.planetPointGenerationInterval) {
this.timeSinceLastPointGeneration = 0;
this.awardToOwner(settings.planetPointGenerationValue);
}
}
private awardToOwner(base: number) {
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) {
@ -175,7 +120,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
}
}
private detectFlip(game: CommandReceiver) {
private detectFlip() {
const currentTeam = this.team;
if (currentTeam === this.lastTeam) {
return;
@ -183,22 +128,15 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
this.lastTeam = currentTeam;
if (currentTeam !== CharacterTeam.neutral) {
const reward = Math.round(
settings.captureFlipPointReward * this.sizePointMultiplier,
);
this.remoteCall('generatedPoints', reward);
game.handleCommand(
new GeneratePointsCommand(
currentTeam === CharacterTeam.blue ? reward : 0,
currentTeam === CharacterTeam.red ? reward : 0,
),
this.remoteCall(
'generatedPoints',
Math.round(settings.captureFlipPointReward * this.sizePointMultiplier),
);
this.awardToOwner(settings.captureFlipPointReward);
if (this.isKeystone) {
game.handleCommand(
new AnnounceCommand(
`Team <span class="${currentTeam}">${currentTeam}</span> captured the Heart`,
),
this.game.announce(
`Team <span class="${currentTeam}">${currentTeam}</span> captured the Heart`,
);
}
}
@ -206,22 +144,12 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
const control = Math.abs(this.ownership - 0.5) / 0.5;
const lightness = mix(settings.lampMinLightness, settings.lampMaxLightness, control);
const color = settings.palette[settings.colorIndices[currentTeam]];
this.lamps.forEach((lamp) => lamp.queueSetLight(color, lightness));
this.remoteCall('onFlipped', currentTeam);
}
public getPropertyUpdates(): PropertyUpdatesForObject {
return new PropertyUpdatesForObject(this.id, [
new UpdatePropertyCommand('ownership', this.ownership, 0),
// Stream the spin rate as the rate-of-change so the client can keep the
// angle moving when snapshots run late (see planet-view.ts).
new UpdatePropertyCommand('rotation', this.rotation, this.rotationSpeed),
]);
}
public takeControl(team: CharacterTeam, deltaTime: number) {
private takeControl(team: CharacterTeam, deltaTime: number) {
if (team === CharacterTeam.blue) {
this.ownership -= (0.5 / settings.takeControlTimeInSeconds) * deltaTime;
} else if (team === CharacterTeam.red) {
@ -232,10 +160,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
-Math.sign(this.ownership - 0.5) *
(0.5 / settings.loseControlTimeInSeconds) *
deltaTime;
if (
(previous < 0.5 && this.ownership > 0.5) ||
(previous > 0.5 && this.ownership < 0.5)
) {
if ((previous - 0.5) * (this.ownership - 0.5) < 0) {
this.ownership = 0.5;
}
}
@ -243,30 +168,10 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
this.ownership = clamp01(this.ownership);
}
public get boundingBox(): ImmutableBoundingBox {
if (!this._boundingBox) {
// The polygon spins about its centre (see distance), so this static box
// has to cover every orientation, not just the spawn-time one: take the
// circumscribed circle around the rotation centre.
const maxVertexDistance = this.vertices.reduce(
(max, vertex) => Math.max(max, vec2.distance(this.center, vertex)),
0,
);
this._boundingBox = 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;
public getPropertyUpdates(): PropertyUpdatesForObject {
return new PropertyUpdatesForObject(this.id, [
new UpdatePropertyCommand('ownership', this.ownership, 0),
new UpdatePropertyCommand('rotation', this.rotation, this.angularVelocity),
]);
}
}

View file

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

View file

@ -1,54 +1,28 @@
import { vec2 } from 'gl-matrix';
import { BoundingBoxBase } from './bounding-box-base';
export class BoundingBox extends BoundingBoxBase {
public get xMin(): number {
return this._xMin;
export class BoundingBox {
constructor(
public xMin = 0,
public xMax = 0,
public yMin = 0,
public yMax = 0,
) {}
public static ofCircle(center: vec2, radius: number): BoundingBox {
return new BoundingBox(
center[0] - radius,
center[0] + radius,
center[1] - radius,
center[1] + radius,
);
}
public set xMin(value: number) {
this._xMin = value;
}
public set xMax(value: number) {
this._xMax = value;
}
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);
public intersects(other: BoundingBox): boolean {
return (
this.xMin < other.xMax &&
this.xMax > other.xMin &&
this.yMin < other.yMax &&
this.yMax > other.yMin
);
}
}

View file

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

View file

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

View file

@ -1,7 +1,6 @@
import { vec2 } from 'gl-matrix';
import {
CommandReceiver,
Circle,
PlayerInformation,
CharacterTeam,
Random,
@ -10,53 +9,64 @@ import {
boundRadius,
evaluateSdf,
} from 'shared';
import { PhysicalContainer } from '../physics/containers/physical-container';
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
import { PhysicalContainer } from '../physics/physical-container';
import { BoundingBox } from '../physics/bounding-box';
import { CharacterPhysical } from '../objects/character-physical';
import { PlanetPhysical } from '../objects/planet-physical';
import { PlanetPhysical, planetsIn } from '../objects/planet-physical';
import { PlayerContainer } from './player-container';
const maximumNameLength = 40;
export interface Score {
kills: number;
deaths: number;
}
export abstract class PlayerBase extends CommandReceiver {
public character?: CharacterPhysical | null;
public character: CharacterPhysical | null = null;
public center: vec2 = vec2.create();
protected sumKills = 0;
protected sumDeaths = 0;
protected lastCharacter?: CharacterPhysical;
protected timeUntilRespawn = 0;
private kills: number;
private deaths: number;
constructor(
protected readonly playerInfo: PlayerInformation,
protected readonly playerContainer: PlayerContainer,
protected readonly objectContainer: PhysicalContainer,
public readonly team: CharacterTeam,
score: Score = { kills: 0, deaths: 0 },
) {
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(
// Coerce, don't just truncate: input is untrusted (from deserialize), and
// arrays have `.slice()` too — a non-string name would throw inside the
// reviver and kill the recipient's whole message batch.
sanitizeName(this.playerInfo.name, maximumNameLength),
this.sumKills,
this.sumDeaths,
this.kills,
this.deaths,
this.team,
this.objectContainer,
this.findEmptyPositionForPlayer(this.findSpawnCenter()),
);
this.objectContainer.addObject(this.character);
this.center = this.character.center;
return this.character;
}
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 {
if (this.character) {
this.center = this.character.center;
@ -65,33 +75,25 @@ export abstract class PlayerBase extends CommandReceiver {
return this.character;
}
this.sumDeaths++;
this.sumKills = this.character.killCount;
this.onCharacterDied(this.character);
this.deaths++;
this.lastCharacter = this.character;
this.character = null;
this.timeUntilRespawn = settings.playerDiedTimeout;
return undefined;
}
if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) {
this.onBeforeRespawn();
this.createCharacter();
this.center = this.character!.center;
}
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 {
const planets = this.objectContainer
.findIntersecting(
getBoundingBoxOfCircle(new Circle(vec2.create(), settings.worldRadius * 2)),
)
.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical);
const planets = planetsIn(
this.objectContainer.findIntersecting(
BoundingBox.ofCircle(vec2.create(), settings.worldRadius * 2),
),
);
const friendly = planets.filter((p) => p.team === this.team);
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));
// candidates is non-empty here, so choose() always returns a planet.
return vec2.clone(Random.choose(safe.length ? safe : candidates)!.center);
}
public restoreScore(kills: number, deaths: number) {
this.sumKills = kills;
this.sumDeaths = deaths;
}
protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 {
private findEmptyPositionForPlayer(preferredCenter: vec2): vec2 {
let rotation = 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 roomiestClearance = -Infinity;
for (let attempt = 0; attempt < 512; attempt++) {
const playerPosition = vec2.fromValues(
radius * Math.cos(rotation) + preferredCenter.x,
radius * Math.sin(rotation) + preferredCenter.y,
const position = vec2.fromValues(
radius * Math.cos(rotation) + preferredCenter[0],
radius * Math.sin(rotation) + preferredCenter[1],
);
const playerBoundingCircle = new Circle(playerPosition, boundRadius);
const playerBoundingBox = getBoundingBoxOfCircle(playerBoundingCircle);
const possibleIntersectors =
this.objectContainer.findIntersecting(playerBoundingBox);
const clearance = evaluateSdf(playerBoundingCircle.center, possibleIntersectors);
if (clearance >= playerBoundingCircle.radius) {
return playerPosition;
const clearance = evaluateSdf(
position,
this.objectContainer.findIntersecting(
BoundingBox.ofCircle(position, boundRadius),
),
);
if (clearance >= boundRadius) {
return position;
}
if (clearance > roomiestClearance) {
roomiestClearance = clearance;
roomiestPosition = playerPosition;
roomiestPosition = position;
}
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 { PhysicalContainer } from '../physics/containers/physical-container';
import { PhysicalContainer } from '../physics/physical-container';
import { NPC } from './npc';
import { Player } from './player';
import { PlayerBase } from './player-base';
import { ServerFullError } from './server-full-error';
import { PlayerBase, Score } from './player-base';
// Team and score carried over from the connection a reconnecting client is
// replacing, so it resumes the match instead of starting from zero.
export interface CarriedScore {
export interface CarriedScore extends Score {
team: CharacterTeam;
kills: number;
deaths: number;
}
export class PlayerContainer {
private _players: Array<Player> = [];
private _npcs: Array<NPC> = [];
private npcs: Array<NPC> = [];
constructor(
private readonly objects: PhysicalContainer,
@ -26,14 +29,14 @@ export class PlayerContainer {
this.createNPCs();
}
public createNPCs() {
private createNPCs() {
const newNpcCount = Math.min(
this.playerMaxCount - this._players.length - this._npcs.length,
this.npcMaxCount - this._npcs.length,
this.playerMaxCount - this._players.length - this.npcs.length,
this.npcMaxCount - this.npcs.length,
);
for (let i = 0; i < newNpcCount; i++) {
const name = `🤖 ${Random.choose(settings.npcNames)}`;
this._npcs.push(
this.npcs.push(
new NPC({ name }, this, this.objects, this.getTeamOfNextPlayer(true)),
);
}
@ -44,31 +47,20 @@ export class PlayerContainer {
socket: Socket,
carried?: CarriedScore,
): Player {
if (this._players.length >= this.playerMaxCount) {
throw new ServerFullError();
}
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 =
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();
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);
if (carried) {
player.restoreScore(carried.kills, carried.deaths);
}
return player;
}
public get players(): Array<PlayerBase> {
return [...this._players, ...this._npcs];
return [...this._players, ...this.npcs];
}
public get count(): number {
@ -79,20 +71,22 @@ export class PlayerContainer {
return this._players.length >= this.playerMaxCount;
}
// Real connected players only — NPCs have no socket and are excluded.
public get connectedPlayerRttsMs(): Array<number> {
return this._players.map((p) => p.rttMs);
}
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._npcs.forEach((p) => p.step(deltaTimeInSeconds));
this.npcs.forEach((p) => p.step(deltaTimeInSeconds));
}
public stepCommunication(deltaTimeInSeconds: number) {
this._players.forEach((p) => p.stepCommunications(deltaTimeInSeconds));
public stepCommunication(
deltaTimeInSeconds: number,
propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined,
) {
this._players.forEach((p) =>
p.stepCommunications(deltaTimeInSeconds, propertyUpdatesOf),
);
}
public endGame(winner: CharacterTeam) {
@ -120,12 +114,8 @@ export class PlayerContainer {
}
public deletePlayer(player: Player) {
const had = this._players.includes(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) {
if (this._players.includes(player)) {
this._players = this._players.filter((p) => p !== player);
this.createNPCs();
}
}

View file

@ -31,44 +31,42 @@ import {
isFiniteNumber,
} from 'shared';
import { Socket } from 'socket.io';
import { BoundingBox } from '../physics/bounding-boxes/bounding-box';
import { PhysicalContainer } from '../physics/containers/physical-container';
import { BoundingBox } from '../physics/bounding-box';
import { PhysicalContainer } from '../physics/physical-container';
import { PlayerContainer } from './player-container';
import { PlayerBase } from './player-base';
import { PlayerBase, Score } from './player-base';
import { CharacterPhysical } from '../objects/character-physical';
const pingIntervalSeconds = 1;
const minimumAspectRatio = 0.2;
const maximumAspectRatio = 8;
const maximumBufferedBytes = settings.maxBufferedBytesPerClient;
const sheddableWhenBackedUp: ReadonlyArray<string> = [
PropertyUpdatesForObjects.type,
UpdateMinimap.type,
InputAcknowledgement.type,
];
export class Player extends PlayerBase {
private aspectRatio: number = 16 / 9;
public reconnectToken = '';
public rttMs = 0;
private aspectRatio = 16 / 9;
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 commandsToBeSent: Array<Command> = [];
private winnerTeam?: CharacterTeam;
private lastInputClientTimeMs = 0;
private lastInputReceiptMs = 0;
private lastLeapClientTimeMs = 0;
public reconnectToken = '';
// Server-side latency stat, reported in GameServer's periodic log.
public rttMs = 0;
private timeSinceLastPing = 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 nextPingNonce = 1;
// Input below is untrusted: every command arrives from `deserialize`
// (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.
// Input is untrusted: one non-finite value reaching the simulation leaves a character that never dies.
protected commandExecutors: CommandExecutors = {
[SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) => {
this.aspectRatio = finiteInRange(
@ -84,7 +82,7 @@ export class Player extends PlayerBase {
return;
}
this.observeClientTime(c.clientTimeMs);
this.character?.handleMovementAction(new MoveActionCommand(direction, 0));
this.character?.setMoveDirection(direction);
},
[PrimaryActionCommand.type]: (c: PrimaryActionCommand) => {
const position = finiteVec2(c.position, settings.maxClientPositionMagnitude);
@ -98,48 +96,34 @@ export class Player extends PlayerBase {
if (!isFiniteNumber(c.clientTimeMs)) {
return;
}
// Record receipt whether or not leap() accepts it: the predictor must
// stop replaying this leap either way.
this.lastLeapClientTimeMs = c.clientTimeMs;
this.observeClientTime(c.clientTimeMs);
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) =>
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(
playerInfo: PlayerInformation,
playerContainer: PlayerContainer,
objectContainer: PhysicalContainer,
team: CharacterTeam,
private readonly socket: Socket,
score?: Score,
) {
super(playerInfo, playerContainer, objectContainer, team);
super(playerInfo, playerContainer, objectContainer, team, score);
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);
}
// Only the outstanding ping's nonce counts, once: a client cannot forge a
// Pong or answer a stale ping to skew the stat. Sitting on the live ping to
// inflate the measurement is bounded by the clamp, and the next ping retires
// the nonce, so any stall costs at most one ping interval.
private observeClientTime(clientTimeMs: number) {
if (isFiniteNumber(clientTimeMs) && clientTimeMs > this.lastInputClientTimeMs) {
this.lastInputClientTimeMs = clientTimeMs;
this.lastInputReceiptMs = performance.now();
}
}
private readonly onPong = (nonce: unknown) => {
if (this.pendingPingNonce === 0 || nonce !== this.pendingPingNonce) {
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() {
this.socket.off(TransportEvents.Pong, this.onPong);
}
protected createCharacter() {
super.createCharacter();
this.objectsInViewArea.add(this.character!);
this.queueCommandSend(new CreatePlayerCommand(this.character!));
protected createCharacter(): CharacterPhysical {
const character = super.createCharacter();
this.objectsInViewArea.add(character);
this.queueCommandSend(new CreatePlayerCommand(character));
return character;
}
private winnerTeam?: CharacterTeam;
public onGameEnded(winnerTeam: CharacterTeam) {
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) {
this.stepLifecycle(deltaTimeInSeconds);
}
protected onCharacterDied(character: CharacterPhysical) {
this.dyingCharacter = character;
public queueCommandSend(command: Command) {
this.commandsToBeSent.push(command);
}
protected onBeforeRespawn() {
if (this.dyingCharacter) {
this.sumKills = Math.max(this.sumKills, this.dyingCharacter.killCount);
this.dyingCharacter = null;
public stepCommunications(
deltaTime: number,
propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined,
) {
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() {
const viewArea = calculateViewArea(this.center, this.aspectRatio, 1.2);
const bb = new BoundingBox();
bb.topLeft = viewArea.topLeft;
bb.size = viewArea.size;
const inViewArea = new Set(
this.objectContainer.findIntersecting(bb).map((o) => o.gameObject),
private queueSnapshot(
propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined,
) {
const { topLeft, size } = calculateViewArea(this.center, this.aspectRatio, 1.2);
const viewBox = new BoundingBox(
topLeft[0],
topLeft[0] + size[0],
topLeft[1] - size[1],
topLeft[1],
);
// The owning character must always be in its own snapshot so the client
// predictor never loses its authoritative anchor.
const inViewArea = new Set(
this.objectContainer.findIntersecting(viewBox).map((o) => o.gameObject),
);
if (this.character) {
inViewArea.add(this.character);
}
// Set membership rather than Array.includes: this diff runs per player per
// snapshot over everything on screen and was quadratic in that.
const newlyIntersecting = [...inViewArea].filter(
(o) => !this.objectsInViewArea.has(o),
);
const noLongerIntersecting = [...this.objectsInViewArea].filter(
(o) => !inViewArea.has(o),
);
const created = [...inViewArea].filter((o) => !this.objectsInViewArea.has(o));
const deleted = [...this.objectsInViewArea].filter((o) => !inViewArea.has(o));
this.objectsInViewArea = inViewArea;
if (noLongerIntersecting.length > 0) {
this.queueCommandSend(
new DeleteObjectsCommand(noLongerIntersecting.map((g) => g.id)),
);
if (deleted.length > 0) {
this.queueCommandSend(new DeleteObjectsCommand(deleted.map((g) => g.id)));
}
if (newlyIntersecting.length > 0) {
this.queueCommandSend(new CreateObjectsCommand(newlyIntersecting));
if (created.length > 0) {
this.queueCommandSend(new CreateObjectsCommand(created));
}
this.queueCommandSend(new UpdateMinimap(this.getMinimapPlayers()));
const propertyUpdates: Array<PropertyUpdatesForObject> = [];
for (const object of this.objectsInViewArea) {
const update = object.getPropertyUpdatesForFrame();
const update = propertyUpdatesOf(object);
if (update) {
propertyUpdates.push(update);
}
@ -239,8 +230,6 @@ export class Player extends PlayerBase {
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) {
this.queueCommandSend(
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> {
return this.playerContainer.players
.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 {
const conn = this.socket.conn as
| { transport?: { socket?: { bufferedAmount?: unknown } } }
@ -311,53 +262,31 @@ export class Player extends PlayerBase {
}
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) {
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.commandsToBeSent = [];
}
public get scoreSnapshot(): { kills: number; deaths: number } {
return {
kills: Math.max(this.sumKills, this.character?.killCount ?? 0),
deaths: this.sumDeaths,
};
}
private handleAnnouncements() {
let announcement = '';
private queueAnnouncement() {
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) {
announcement = `Reviving in ${Math.round(this.timeUntilRespawn)}…`;
}
if (announcement) {
this.queueCommandSend(new ServerAnnouncement(announcement));
this.queueCommandSend(
new ServerAnnouncement(`Reviving in ${Math.round(this.timeUntilRespawn)}…`),
);
}
}
}

View file

@ -33,8 +33,7 @@ module.exports = (env, argv) => ({
minimizer: [
new TerserJSPlugin({
exclude: /node_modules/,
// The custom serialization protocol keys on class names, so they must
// survive minification (see shared/src/serialization).
// The serialization protocol keys on class names.
terserOptions: {
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 prettierRecommended = require('eslint-plugin-prettier/recommended');
const unusedImports = require('eslint-plugin-unused-imports');
@ -25,7 +24,6 @@ module.exports = tseslint.config(
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-empty-function': 'off',
// Renamed from the former no-var-requires; keep require() allowed.
'@typescript-eslint/no-require-imports': 'off',
},
},

View file

@ -1,6 +1,5 @@
import { glMatrix } from 'gl-matrix';
import {
applyArrayPlugins,
LampBase,
overrideDeserialization,
PlanetBase,
@ -14,20 +13,12 @@ import '../static/favicons/apple-touch-icon.png';
import '../static/favicons/favicon-16x16.png';
import '../static/favicons/favicon-32x32.png';
import '../static/favicons/favicon.ico';
import ResizeObserver from 'resize-observer-polyfill';
import { LandingPageBackground } from './scripts/landing-page-background';
import { JoinFormHandler } from './scripts/join-form-handler';
import { handleFullScreen } from './scripts/helper/handle-full-screen';
import { Game } from './scripts/game';
import ResizeObserver from 'resize-observer-polyfill';
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 { initializeOptions } from './scripts/options-handler';
import { SoundHandler, Sounds } from './scripts/sound-handler';
import { VibrationHandler } from './scripts/vibration-handler';
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';
glMatrix.setMatrixArrayType(Array);
applyArrayPlugins();
overrideDeserialization(CharacterBase, CharacterView);
overrideDeserialization(PlanetBase, PlanetView);
overrideDeserialization(LampBase, LampView);
overrideDeserialization(ProjectileBase, ProjectileView);
const landingUI = document.querySelector('#landing-ui') as HTMLElement;
const nameInput = document.querySelector('#name') as HTMLInputElement;
const joinGameForm = document.querySelector('#join-game-form') as HTMLFormElement;
const serverContainer = document.querySelector('#server-container') as HTMLElement;
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
const overlay = document.querySelector('#overlay') as HTMLElement;
const settings = document.querySelector('#settings') as HTMLElement;
const toggleSettingsButton = document.querySelector(
'#toggle-settings-container',
) as HTMLElement;
const minimize = document.querySelector('#minimize') as HTMLElement;
const maximize = document.querySelector('#maximize') as HTMLElement;
const logoutButton = document.querySelector('#logout') as HTMLElement;
const enableSounds = document.querySelector('#enable-sounds') as HTMLInputElement;
const enableMusic = document.querySelector('#enable-music') as HTMLInputElement;
const enableVibration = document.querySelector('#enable-vibration') as HTMLInputElement;
const spinner = document.querySelector('#spinner-container') as HTMLElement;
const query = <T extends HTMLElement>(selector: string) =>
document.querySelector(selector) as T;
const landingUI = query('#landing-ui');
const nameInput = query<HTMLInputElement>('#name');
const joinGameForm = query<HTMLFormElement>('#join-game-form');
const serverContainer = query('#server-container');
const canvas = query<HTMLCanvasElement>('canvas');
const overlay = query('#overlay');
const settings = query('#settings');
const toggleSettingsButton = query('#toggle-settings-container');
const minimize = query('#minimize');
const maximize = query('#maximize');
const logoutButton = query('#logout');
const enableSounds = query<HTMLInputElement>('#enable-sounds');
const enableMusic = query<HTMLInputElement>('#enable-music');
const enableVibration = query<HTMLInputElement>('#enable-vibration');
const spinner = query('#spinner-container');
const joinNotice = document.createElement('p');
joinNotice.className = 'join-notice';
joinNotice.style.display = 'none';
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 = () => {
settings.className = settings.className === 'open' ? '' : 'open';
SoundHandler.play(Sounds.click);
@ -76,21 +74,19 @@ const applyServerContainerShadows = () => {
const bottomShadow = 'inset 0 8px 8px -8px rgba(0, 0, 0, 0.4)';
const { scrollHeight, clientHeight, scrollTop } = serverContainer;
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 {
if (scrollHeight <= clientHeight) {
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 () => {
let game: Game;
let game: Game | undefined;
const storedUserName = localStorage.getItem('userName');
if (storedUserName) {
@ -113,7 +109,7 @@ const main = async () => {
document.addEventListener('click', firstClickListener);
if (!VibrationHandler.isVibrationEnabledHeuristics) {
hide(document.querySelector("label[for='enable-vibration']") as HTMLElement, true);
setDisplayed(query("label[for='enable-vibration']"), null);
}
handleFullScreen(minimize, maximize);
@ -122,28 +118,28 @@ const main = async () => {
new ResizeObserver(applyServerContainerShadows).observe(serverContainer);
serverContainer.addEventListener('scroll', applyServerContainerShadows);
OptionsHandler.initialize({
initializeOptions({
soundsEnabled: enableSounds,
vibrationEnabled: enableVibration,
musicEnabled: enableMusic,
});
logoutButton.addEventListener('click', () => {
game.destroy();
game?.destroy();
toggleSettings();
});
window.onpopstate = () => game.destroy();
window.onpopstate = () => game?.destroy();
for (;;) {
show(spinner);
hide(logoutButton, true);
show(landingUI, true, 'flex');
setVisible(spinner, true);
setDisplayed(logoutButton, null);
setDisplayed(landingUI, 'flex');
const background = new LandingPageBackground(canvas);
const joinHandler = new JoinFormHandler(joinGameForm, serverContainer);
await background.renderer;
hide(spinner);
setVisible(spinner, false);
const playerDecision = await joinHandler.getPlayerDecision();
@ -153,29 +149,25 @@ const main = async () => {
history.pushState(true, '');
}
hide(landingUI, true);
show(spinner);
setDisplayed(landingUI, null);
setVisible(spinner, true);
background.destroy();
game = new Game(playerDecision, canvas, overlay);
const gameOver = game.start();
await game.started;
hide(spinner);
show(logoutButton, true, 'block');
setVisible(spinner, false);
setDisplayed(logoutButton, 'block');
await gameOver;
const reason = game.lastRejectionReason;
if (reason) {
joinNotice.innerText = Game.rejectionText(reason);
joinNotice.style.display = 'block';
} else {
joinNotice.style.display = 'none';
}
const reason = game.rejectionReason;
joinNotice.innerText = reason ? Game.rejectionText(reason) : '';
setDisplayed(joinNotice, reason ? 'block' : null);
}
};
main().catch((error) => {
console.error(error);
hide(spinner);
setVisible(spinner, false);
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';
let element: HTMLElement | undefined;
@ -24,7 +24,7 @@ function beginChargeIndicator(x: number, y: number, followPointer = false) {
function endChargeIndicator() {
if (element) {
cancelAnimationFrame(raf);
element.parentElement?.removeChild(element);
element.remove();
element = undefined;
}
}
@ -34,7 +34,7 @@ function updateChargeIndicator() {
return;
}
const charge = holdDurationToCharge((performance.now() - heldSince) / 1000);
const charge = chargeHeldSince(heldSince);
element.style.opacity = charge < 0.12 ? '0' : '1';
element.style.background = `conic-gradient(rgba(255, 255, 255, 0.85) ${
charge * 360
@ -44,15 +44,14 @@ function updateChargeIndicator() {
if (isFollowingPointer) {
const cursor = pointer.displayPosition;
if (cursor) {
element.style.left = `${cursor.x}px`;
element.style.top = `${cursor.y}px`;
element.style.left = `${cursor[0]}px`;
element.style.top = `${cursor[1]}px`;
}
}
raf = requestAnimationFrame(updateChargeIndicator);
}
// The one public surface; the functions above stay module-private.
export const ChargeIndicator = {
begin: beginChargeIndicator,
end: endChargeIndicator,

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