Fix net code

This commit is contained in:
Andras Schmelczer 2026-06-14 15:01:36 +01:00
parent 1f10b9c750
commit a1fb6755c7
23 changed files with 408 additions and 236 deletions

View file

@ -0,0 +1,9 @@
import { Command } from 'shared';
// Internal request from a game object (e.g. a keystone planet flipping) asking
// the GameServer to broadcast a one-off announcement to every connected client.
export class AnnounceCommand extends Command {
public constructor(public readonly text: string) {
super();
}
}

View file

@ -1,9 +1,8 @@
import { vec2 } from 'gl-matrix';
import { Random, PlanetBase, hsl, settings } from 'shared';
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 { evaluateSdf } from './physics/functions/evaluate-sdf';
import { Physical } from './physics/physicals/physical';
export const createWorld = (objectContainer: PhysicalContainer) => {
@ -45,13 +44,16 @@ export const createWorld = (objectContainer: PhysicalContainer) => {
) {
const planet =
objects.length === 0
? new PlanetPhysical(
? // The first, central giant is the keystone "Heart": a named,
// always-contested focal objective the whole match orbits.
new PlanetPhysical(
PlanetBase.createPlanetVertices(
position,
Random.getRandomInRange(1600, 2400),
Random.getRandomInRange(1600, 2400),
Random.getRandomInRange(80, 300),
),
true,
)
: new PlanetPhysical(
PlanetBase.createPlanetVertices(

View file

@ -21,6 +21,7 @@ import { Options } from './options';
import { PlayerContainer } from './players/player-container';
import { StepCommand } from './commands/step';
import { GeneratePointsCommand } from './commands/generate-points';
import { AnnounceCommand } from './commands/announce';
const gameStateSubscribedRoom = 'gameStateSubscribedRoom';
@ -63,6 +64,8 @@ export class GameServer extends CommandReceiver {
protected commandExecutors: CommandExecutors = {
[GeneratePointsCommand.type]: this.addPoints.bind(this),
[AnnounceCommand.type]: ({ text }: AnnounceCommand) =>
this.players.queueCommandForEachClient(new ServerAnnouncement(text)),
};
constructor(
@ -164,6 +167,7 @@ export class GameServer extends CommandReceiver {
}
private timeSinceLastPointUpdate = 0;
private physicsAccumulator = 0;
private handlePhysics() {
const delta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds({ setAsBase: true });
@ -183,14 +187,28 @@ export class GameServer extends CommandReceiver {
);
}
let scaledDelta = delta;
if (this.isInEndGame) {
this.timeScaling *= Math.pow(settings.endGameDeltaScaling, delta);
scaledDelta /= this.timeScaling;
const fixedDelta = settings.targetPhysicsDeltaTimeInSeconds;
const maxSubstepsPerFrame = 5;
this.physicsAccumulator += delta;
let substeps = Math.floor(this.physicsAccumulator / fixedDelta);
if (substeps > maxSubstepsPerFrame) {
substeps = maxSubstepsPerFrame;
this.physicsAccumulator = 0;
} else {
this.physicsAccumulator -= substeps * fixedDelta;
}
for (let i = 0; i < substeps; i++) {
let scaledDelta = fixedDelta;
if (this.isInEndGame) {
this.timeScaling *= Math.pow(settings.endGameDeltaScaling, fixedDelta);
scaledDelta /= this.timeScaling;
}
this.objects.handleCommand(new StepCommand(scaledDelta, this));
this.players.step(scaledDelta);
}
this.objects.handleCommand(new StepCommand(scaledDelta, this));
this.players.step(scaledDelta);
this.players.stepCommunication(delta);
this.objects.resetRemoteCalls();
@ -198,7 +216,7 @@ export class GameServer extends CommandReceiver {
setTimeout(
this.handlePhysics.bind(this),
Math.max(0, settings.targetPhysicsDeltaTimeInSeconds - physicsDelta) * 1000,
Math.max(0, fixedDelta - physicsDelta) * 1000,
);
}

View file

@ -1,6 +0,0 @@
export const interpolateAngles = (from: number, to: number, q: number) => {
const max = Math.PI * 2;
const possibleDistance = (to - from) % max;
const shorterDistance = ((2 * possibleDistance) % max) - possibleDistance;
return from + shorterDistance * q;
};

View file

@ -499,8 +499,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
// around the planet centre by the same per-tick angle the renderer and the
// collision SDF use, so the player is carried around and turned with the
// surface instead of it sliding frictionlessly underneath. The positional
// change becomes velocity in setPropertyUpdates, so the motion syncs and the
// client extrapolates it smoothly.
// change becomes velocity in setPropertyUpdates, keeping the streamed
// rate-of-change consistent with the streamed positions.
private carryWithRotatingPlanet(deltaTimeInSeconds: number) {
const planet = this.currentPlanet;
if (!planet) {

View file

@ -5,6 +5,7 @@ import {
clamp01,
id,
mix,
Random,
serializesTo,
settings,
PlanetBase,
@ -28,6 +29,16 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
public readonly sizePointMultiplier: number;
// Planets slowly spin. The angle is authoritative here and streamed to the
// client (see getPropertyUpdates), so the rendered outline and this collision
// polygon turn as one rigid body. cos/sin are memoised per angle because
// distance() is called many times per tick by the raymarcher and SDF sampling.
private rotation = 0;
private readonly rotationSpeed: number;
private cachedRotation = Number.NaN;
private cosRotation = 1;
private sinRotation = 0;
private _boundingBox?: ImmutableBoundingBox;
private readonly lamps: Array<LampPhysical> = [];
@ -47,23 +58,30 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
const sizeClass = clamp01(
(this.radius - settings.planetMinReferenceRadius) /
(settings.planetMaxReferenceRadius - settings.planetMinReferenceRadius),
(settings.planetMaxReferenceRadius - settings.planetMinReferenceRadius),
);
this.sizePointMultiplier = mix(1, settings.planetSizePointMultiplierMax, sizeClass);
this.rotationSpeed =
(0.05 + Random.getRandom() * 0.07) * (Random.getRandom() < 0.5 ? -1 : 1);
}
public distance(target: vec2): number {
// Evaluate the SDF in the planet's own rotating frame so this collision
// outline turns in lockstep with the rendered planet (see planet-shape.ts).
const local = this.toLocalFrame(target);
const startEnd = this.vertices[0];
let vb = startEnd;
let d = vec2.squaredDistance(target, vb);
let d = vec2.dist(local, vb);
let sign = 1;
for (let i = 1; i <= this.vertices.length; i++) {
const va = vb;
vb = i === this.vertices.length ? startEnd : this.vertices[i];
const targetFromDelta = vec2.subtract(vec2.create(), target, va);
const targetFromDelta = vec2.subtract(vec2.create(), local, va);
const toFromDelta = vec2.subtract(vec2.create(), vb, va);
const h = clamp01(
vec2.dot(targetFromDelta, toFromDelta) / vec2.squaredLength(toFromDelta),
@ -75,8 +93,8 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
);
if (
(target.y >= va.y && target.y < vb.y && ds.y > 0) ||
(target.y < va.y && target.y >= vb.y && ds.y <= 0)
(local.y >= va.y && local.y < vb.y && ds.y > 0) ||
(local.y < va.y && local.y >= vb.y && ds.y <= 0)
) {
sign *= -1;
}
@ -87,11 +105,35 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
return sign * d;
}
// Rotate a world point by -rotation about the centre, matching the shader's
// `localTarget = center + R(rotation) * (target - center)` transform exactly.
private toLocalFrame(target: vec2): vec2 {
if (this.rotation !== this.cachedRotation) {
this.cachedRotation = this.rotation;
this.cosRotation = Math.cos(this.rotation);
this.sinRotation = Math.sin(this.rotation);
}
const dx = target.x - this.center.x;
const dy = target.y - this.center.y;
return vec2.fromValues(
this.center.x + this.cosRotation * dx - this.sinRotation * dy,
this.center.y + this.sinRotation * dx + this.cosRotation * dy,
);
}
// Signed angular velocity in rad/s, exposed so a character standing on the
// planet can ride its spin (see CharacterPhysical.carryWithRotatingPlanet).
public get angularVelocity(): number {
return this.rotationSpeed;
}
public get team(): CharacterTeam {
return Math.abs(this.ownership - 0.5) < 0.1
? CharacterTeam.neutral
: this.ownership < 0.5
? CharacterTeam.decla
? CharacterTeam.blue
: CharacterTeam.red;
}
@ -105,7 +147,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
);
game.handleCommand(
new GeneratePointsCommand(
this.team === CharacterTeam.decla ? value : 0,
this.team === CharacterTeam.blue ? value : 0,
this.team === CharacterTeam.red ? value : 0,
),
);
@ -113,6 +155,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
}
private step({ deltaTimeInSeconds, game }: StepCommand) {
this.rotation += deltaTimeInSeconds * this.rotationSpeed;
this.timeSinceLastPointGeneration += deltaTimeInSeconds;
// In reverse order, so that teams can achieve a 100% control.
@ -135,7 +178,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
this.remoteCall('generatedPoints', reward);
game.handleCommand(
new GeneratePointsCommand(
currentTeam === CharacterTeam.decla ? reward : 0,
currentTeam === CharacterTeam.blue ? reward : 0,
currentTeam === CharacterTeam.red ? reward : 0,
),
);
@ -153,11 +196,14 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
public getPropertyUpdates(): PropertyUpdatesForObject {
return new PropertyUpdatesForObject(this.id, [
new UpdatePropertyCommand('ownership', this.ownership, 0),
// Stream rotation with rotationSpeed as the rate-of-change so the client
// can keep the angle moving when snapshots run late (see planet-view.ts).
new UpdatePropertyCommand('rotation', this.rotation, this.rotationSpeed),
]);
}
public takeControl(team: CharacterTeam, deltaTime: number) {
if (team === CharacterTeam.decla) {
if (team === CharacterTeam.blue) {
this.ownership -= (0.5 / settings.takeControlTimeInSeconds) * deltaTime;
} else if (team === CharacterTeam.red) {
this.ownership += (0.5 / settings.takeControlTimeInSeconds) * deltaTime;
@ -180,22 +226,20 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
public get boundingBox(): ImmutableBoundingBox {
if (!this._boundingBox) {
const { xMin, xMax, yMin, yMax } = this.vertices.reduce(
(extremities, vertex) => ({
xMin: Math.min(extremities.xMin, vertex.x),
xMax: Math.max(extremities.xMax, vertex.x),
yMin: Math.min(extremities.yMin, vertex.y),
yMax: Math.max(extremities.yMax, vertex.y),
}),
{
xMin: Infinity,
xMax: -Infinity,
yMin: Infinity,
yMax: -Infinity,
},
// The polygon spins about its centre (see distance), so this static box
// has to cover every orientation, not just the spawn-time one: take the
// circumscribed circle around the rotation centre.
const maxVertexDistance = this.vertices.reduce(
(max, vertex) => Math.max(max, vec2.distance(this.center, vertex)),
0,
);
this._boundingBox = new ImmutableBoundingBox(xMin, xMax, yMin, yMax);
this._boundingBox = new ImmutableBoundingBox(
this.center.x - maxVertexDistance,
this.center.x + maxVertexDistance,
this.center.y - maxVertexDistance,
this.center.y + maxVertexDistance,
);
}
return this._boundingBox;

View file

@ -1,7 +0,0 @@
import { vec2 } from 'gl-matrix';
import { PhysicalBase } from '../physicals/physical-base';
export const evaluateSdf = (target: vec2, objects: Array<PhysicalBase>) =>
objects
.filter((i) => i.canCollide)
.reduce((min, i) => (min = Math.min(min, i.distance(target))), 1000000);

View file

@ -1,89 +0,0 @@
import { vec2 } from 'gl-matrix';
import { GameObject } from 'shared';
import { CirclePhysical } from '../../objects/circle-physical';
import { evaluateSdf } from './evaluate-sdf';
import { Physical } from '../physicals/physical';
import { ReactToCollisionCommand } from '../../commands/react-to-collision';
export const moveCircle = (
circle: CirclePhysical,
delta: vec2,
possibleIntersectors: Array<Physical>,
ignoreCollision = false,
): {
hitSurface: boolean;
normal?: vec2;
hitObject?: GameObject;
} => {
const direction = vec2.clone(delta);
if (vec2.length(delta) > 0) {
vec2.normalize(direction, direction);
}
const deltaLength = vec2.length(delta);
let travelled = 0;
const rayEnd = vec2.create();
let prevMinDistance = 0;
while (travelled < deltaLength) {
travelled += prevMinDistance;
vec2.add(
rayEnd,
circle.center,
vec2.scale(vec2.create(), direction, Math.min(travelled, deltaLength)),
);
const minDistance = evaluateSdf(rayEnd, possibleIntersectors);
if (minDistance < circle.radius) {
const intersecting = possibleIntersectors.find(
(i) => i.distance(rayEnd) <= circle.radius,
)!;
if (ignoreCollision) {
circle.center = vec2.add(circle.center, circle.center, delta);
} else {
intersecting.handleCommand(new ReactToCollisionCommand(circle.gameObject));
circle.handleCommand(new ReactToCollisionCommand(intersecting.gameObject));
}
vec2.add(
rayEnd,
circle.center,
vec2.scale(vec2.create(), direction, travelled - prevMinDistance),
);
vec2.copy(circle.center, rayEnd);
const dx =
evaluateSdf(vec2.add(vec2.create(), rayEnd, vec2.fromValues(0.01, 0)), [
intersecting,
]) -
evaluateSdf(vec2.add(vec2.create(), rayEnd, vec2.fromValues(-0.01, 0)), [
intersecting,
]);
const dy =
evaluateSdf(vec2.add(vec2.create(), rayEnd, vec2.fromValues(0, 0.01)), [
intersecting,
]) -
evaluateSdf(vec2.add(vec2.create(), rayEnd, vec2.fromValues(0, -0.01)), [
intersecting,
]);
const normal = vec2.fromValues(dx, dy);
vec2.normalize(normal, normal);
return {
hitSurface: true,
normal,
hitObject: intersecting?.gameObject,
};
}
prevMinDistance = minDistance;
}
vec2.add(circle.center, circle.center, delta);
return {
hitSurface: false,
};
};

View file

@ -1,3 +1,4 @@
import { performance } from 'perf_hooks';
import { vec2 } from 'gl-matrix';
import {
CommandExecutors,
@ -126,6 +127,7 @@ export class Player extends PlayerBase {
this.objectsPreviouslyInViewArea
.map((o) => o.getPropertyUpdates())
.filter((u) => u) as Array<PropertyUpdatesForObject>,
performance.now() / 1000,
),
);
}