This commit is contained in:
Andras Schmelczer 2026-08-22 15:29:33 +01:00
commit b9274a42c7
35 changed files with 480 additions and 457 deletions

View file

@ -48,9 +48,6 @@ export class GameServer extends CommandReceiver {
private isInEndGame = false;
private timeScaling = 1;
private serverName: string;
private playerLimit: number;
private initialize() {
const previousPlayers = this.players;
this.releaseJoinedSockets();
@ -85,9 +82,6 @@ export class GameServer extends CommandReceiver {
) {
super();
this.serverName = options.name;
this.playerLimit = options.playerLimit;
this.initialize();
io.on('connection', (socket: Socket) => {
@ -274,14 +268,9 @@ export class GameServer extends CommandReceiver {
this.isInEndGame = true;
const endTitleLength = 6;
this.players.endGame(winningTeam);
this.players.queueCommandForEachClient(
new GameEndCommand(winningTeam, endTitleLength, true),
);
setTimeout(() => this.destroy(), endTitleLength * 1000 * 1.1);
}
private destroy() {
this.initialize();
this.players.queueCommandForEachClient(new GameEndCommand());
// Rebuild the world for the next round once the end card has been shown.
setTimeout(() => this.initialize(), endTitleLength * 1000 * 1.1);
}
private timeSinceLastPointUpdate = 0;
@ -406,9 +395,9 @@ export class GameServer extends CommandReceiver {
public get serverInfo(): ServerInformation {
return {
serverName: this.serverName,
serverName: this.options.name,
playerCount: this.players.count,
playerLimit: this.playerLimit,
playerLimit: this.options.playerLimit,
gameStatePercent: this.gameProgress,
};
}

View file

@ -15,10 +15,12 @@ import {
CharacterMovementSnapshot,
mix,
clamp01,
strengthToCharge,
stepCharacterMovement,
applyLeapImpulse,
decayMomentum,
CharacterMovementState,
tickPlanetDetachment,
characterCenter,
CharacterWorld,
GroundSurface,
headRadius,
@ -40,6 +42,14 @@ import { StepCommand } from '../commands/step';
import { ReactToCollisionCommand } from '../commands/react-to-collision';
import { GeneratePointsCommand } from '../commands/generate-points';
// The three body circles, as a value — used to diff a tick's movement into the
// rate of change the client interpolates on.
interface BodyPose {
head: Circle;
leftFoot: Circle;
rightFoot: Circle;
}
@serializesTo(CharacterBase)
export class CharacterPhysical extends CharacterBase implements DynamicPhysical {
public readonly canCollide = true;
@ -47,13 +57,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
private projectileStrength = settings.playerMaxStrength;
// Body geometry (head/foot radii, posture offsets, bound radius) is defined
// once in the shared movement module and imported here, so the authoritative
// body and the client's predicted body are bit-identical by construction
// instead of by a hand-synced "copied verbatim" duplicate. Re-exposed as a
// static only because external callers reference CharacterPhysical.boundRadius.
public static readonly boundRadius = boundRadius;
private timeSinceDying = 0;
private isDestroyed = false;
private timeSinceBorn = 0;
@ -66,20 +69,20 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
private killStreak = 0;
private direction = 0;
private currentPlanet?: PlanetPhysical;
private secondsSinceOnSurface = settings.planetDetachmentSeconds;
// CharacterMovementState, held directly rather than mirrored through an
// adapter: the shared simulation reads and writes these in place, so the
// character simply IS the state it is stepped as.
public direction = 0;
public currentPlanet: GroundSurface | undefined;
public secondsSinceOnSurface = settings.planetDetachmentSeconds;
public readonly bodyVelocity = vec2.create();
private bodyVelocity = vec2.create();
private timeSinceLastLeap = settings.leapCooldownSeconds;
public head: CirclePhysical;
public leftFoot: CirclePhysical;
public rightFoot: CirclePhysical;
private movementState!: CharacterMovementState;
private movementWorld!: CharacterWorld;
private movementActions: Array<MoveActionCommand> = [];
private lastMovementAction: MoveActionCommand = new MoveActionCommand(vec2.create());
@ -122,47 +125,15 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
container.addObject(this.head);
container.addObject(this.leftFoot);
container.addObject(this.rightFoot);
this.initMovementBridge();
}
private initMovementBridge() {
// The movementState object-literal getters/setters below can't use `this`
// (it would bind to the literal), so alias the character instance.
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;
this.movementState = {
head: this.head,
leftFoot: this.leftFoot,
rightFoot: this.rightFoot,
get direction() {
return self.direction;
},
set direction(value: number) {
self.direction = value;
},
get currentPlanet() {
return self.currentPlanet;
},
set currentPlanet(value: GroundSurface | undefined) {
// On the server every ground is a PlanetPhysical (the world only ever
// hands back planets), so this narrowing is safe.
self.currentPlanet = value as PlanetPhysical | undefined;
},
get secondsSinceOnSurface() {
return self.secondsSinceOnSurface;
},
set secondsSinceOnSurface(value: number) {
self.secondsSinceOnSurface = value;
},
bodyVelocity: this.bodyVelocity,
};
this.movementWorld = {
// Same set and order forceAtPosition used: planets in the force field,
// in container-traversal order (so the f64 gravity sum is unchanged).
// The container, presented as the collision/gravity world the shared movement
// queries.
private readonly movementWorld: CharacterWorld = {
// Same set and order forceAtPosition used: planets in the force field, in
// container-traversal order (so the f64 gravity sum is unchanged).
groundsNear: (center, radius) =>
self.container
this.container
.findIntersecting(getBoundingBoxOfCircle(new Circle(center, radius)))
.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical),
stepBody: (body, deltaTimeInSeconds) => {
@ -170,7 +141,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
return hitObject instanceof PlanetPhysical ? hitObject : undefined;
},
};
}
private hasFiredSinceSpawn = false;
private get isSpawnProtected(): boolean {
@ -206,8 +176,10 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
this.movementActions.push(c);
}
// The world only ever hands back planets, so narrowing the shared
// GroundSurface is safe here.
public get groundPlanet(): PlanetPhysical | undefined {
return this.currentPlanet;
return this.currentPlanet as PlanetPhysical | undefined;
}
// The continuous movement state streamed to the owning client, so its
@ -219,7 +191,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
vec2.clone(this.bodyVelocity),
vec2.clone(this.leftFoot.lastNormal),
vec2.clone(this.rightFoot.lastNormal),
this.currentPlanet?.id ?? null,
this.groundPlanet?.id ?? null,
this.secondsSinceOnSurface,
);
}
@ -311,10 +283,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
const strength = Math.min(desiredStrength, this.projectileStrength);
this.projectileStrength -= strength;
const c = clamp01(
(strength - settings.chargeShotStrengthMin) /
(settings.chargeShotStrengthMax - settings.chargeShotStrengthMin),
);
const c = strengthToCharge(strength);
const radius = mix(settings.chargeShotRadiusMin, settings.chargeShotRadiusMax, c);
const speed = mix(settings.chargeShotSpeedMin, settings.chargeShotSpeedMax, c);
@ -367,12 +336,12 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
// Same impulse the client predicts with (shared), so a leap launches
// identically on both sides.
applyLeapImpulse(this.movementState, this.lastMovementAction.direction);
applyLeapImpulse(this, this.lastMovementAction.direction);
this.remoteCall('onLeap');
}
public get boundingBox(): BoundingBoxBase {
return getBoundingBoxOfCircle(new Circle(this.center, CharacterPhysical.boundRadius));
return getBoundingBoxOfCircle(new Circle(this.center, boundRadius));
}
public get gameObject(): this {
@ -380,9 +349,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
}
public get center(): vec2 {
const bodyCenter = vec2.add(vec2.create(), this.head.center, this.leftFoot.center);
vec2.add(bodyCenter, bodyCenter, this.rightFoot.center);
return vec2.scale(bodyCenter, bodyCenter, 1 / 3);
return characterCenter(this.head, this.leftFoot, this.rightFoot);
}
public distance(target: vec2): number {
@ -434,53 +401,42 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
]);
}
private setPropertyUpdates(
oldHead: Circle,
oldLeftFoot: Circle,
oldRightFoot: Circle,
deltaTime: number,
) {
this.headVelocity = new Circle(
// Rate of change of one body part over the tick, which the client's
// interpolator coasts on when a snapshot is late. `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(
oldHead.center,
vec2.subtract(oldHead.center, this.head.center, oldHead.center),
previous.center,
vec2.subtract(previous.center, current.center, previous.center),
1 / deltaTime,
),
(this.head.radius - oldHead.radius) / deltaTime,
(current.radius - previous.radius) / deltaTime,
);
}
this.leftFootVelocity = new Circle(
vec2.scale(
oldLeftFoot.center,
vec2.subtract(oldLeftFoot.center, this.leftFoot.center, oldLeftFoot.center),
1 / deltaTime,
),
(this.leftFoot.radius - oldLeftFoot.radius) / deltaTime,
);
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);
}
this.rightFootVelocity = new Circle(
vec2.scale(
oldRightFoot.center,
vec2.subtract(oldRightFoot.center, this.rightFoot.center, oldRightFoot.center),
1 / deltaTime,
),
(this.rightFoot.radius - oldRightFoot.radius) / deltaTime,
);
private get pose(): BodyPose {
const snapshot = (circle: Circle) =>
new Circle(vec2.clone(circle.center), circle.radius);
return {
head: snapshot(this.head),
leftFoot: snapshot(this.leftFoot),
rightFoot: snapshot(this.rightFoot),
};
}
private step({ deltaTimeInSeconds, game }: StepCommand) {
this.getPoints(game);
this.timeAlive += deltaTimeInSeconds;
this.timeSinceLastLeap += deltaTimeInSeconds;
const oldHead = new Circle(vec2.clone(this.head.center), this.head.radius);
const oldLeftFoot = new Circle(
vec2.clone(this.leftFoot.center),
this.leftFoot.radius,
);
const oldRightFoot = new Circle(
vec2.clone(this.rightFoot.center),
this.rightFoot.radius,
);
const previousPose = this.pose;
if (this.isDestroyed) {
if ((this.timeSinceDying += deltaTimeInSeconds) > settings.spawnDespawnTime) {
@ -489,7 +445,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
this.freeFallCorpse(deltaTimeInSeconds);
this.animateScaling(1 - this.timeSinceDying / settings.spawnDespawnTime);
}
this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds);
this.setPropertyUpdates(previousPose, deltaTimeInSeconds);
return;
}
@ -500,16 +456,11 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
} else {
this.animateScaling(this.timeSinceBorn / settings.spawnDespawnTime);
}
this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds);
this.setPropertyUpdates(previousPose, deltaTimeInSeconds);
return;
}
if (
(this.secondsSinceOnSurface += deltaTimeInSeconds) >
settings.planetDetachmentSeconds
) {
this.currentPlanet = undefined;
}
tickPlanetDetachment(this, deltaTimeInSeconds);
this.timeSinceLastShot += deltaTimeInSeconds;
@ -523,7 +474,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
// The planet tallies who is standing on it and resolves capture itself, so
// a contested rock can freeze instead of two squads silently cancelling.
this.currentPlanet?.registerPresence(this);
this.groundPlanet?.registerPresence(this);
// The whole walking model — gravity gather, movement force, on/off-planet
// branch, posture springs, body-momentum, and stepping the three parts —
@ -531,23 +482,15 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
// drift. Server-only concerns (scoring, health, shooting, spawn/death,
// ownership) stay here around it.
const direction = this.averageAndResetMovementActions();
stepCharacterMovement(
this.movementState,
this.movementWorld,
direction,
deltaTimeInSeconds,
);
stepCharacterMovement(this, this.movementWorld, direction, deltaTimeInSeconds);
this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds);
this.setPropertyUpdates(previousPose, deltaTimeInSeconds);
}
private freeFallCorpse(deltaTime: number) {
const intersecting = this.container.findIntersecting(
getBoundingBoxOfCircle(
new Circle(
this.center,
CharacterPhysical.boundRadius + settings.maxGravityDistance,
),
new Circle(this.center, boundRadius + settings.maxGravityDistance),
),
);
let grounded = false;

View file

@ -1,5 +1,6 @@
import { vec2 } from 'gl-matrix';
import {
applyForce,
Circle,
CommandExecutors,
CommandReceiver,
@ -12,6 +13,7 @@ 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/react-to-collision';
@serializesTo(Circle)
@ -84,11 +86,7 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh
}
public applyForce(force: vec2, timeInSeconds: number) {
vec2.add(
this.velocity,
this.velocity,
vec2.scale(vec2.create(), force, timeInSeconds),
);
applyForce(this, force, timeInSeconds);
}
// Position-resolution for one tick. Delegates to the shared
@ -133,10 +131,11 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh
const sweep = vec2.length(
vec2.scale(vec2.create(), this.velocity, deltaTimeInSeconds),
);
this.radius += sweep;
const intersecting = this.container.findIntersecting(this.boundingBox);
this.radius -= sweep;
return intersecting;
// Grow the query, not the object: inflating and deflating this.radius ran
// the setter (and so re-registered the body's extent) twice mid-tick.
return this.container.findIntersecting(
getBoundingBoxOfCircle(new Circle(this.center, this.radius + sweep)),
);
}
public toArray(): Array<any> {

View file

@ -1,7 +1,8 @@
import { vec2, vec3 } from 'gl-matrix';
import { LampBase, settings, id, serializesTo } from 'shared';
import { Circle, LampBase, settings, id, serializesTo } from 'shared';
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)
@ -17,11 +18,8 @@ export class LampPhysical extends LampBase implements StaticPhysical {
public get boundingBox(): ImmutableBoundingBox {
if (!this._boundingBox) {
this._boundingBox = new ImmutableBoundingBox(
this.center.x - settings.lightCutoffDistance,
this.center.x + settings.lightCutoffDistance,
this.center.y - settings.lightCutoffDistance,
this.center.y + settings.lightCutoffDistance,
this._boundingBox = getBoundingBoxOfCircle(
new Circle(this.center, settings.lightCutoffDistance),
);
}

View file

@ -1,7 +1,7 @@
import { vec2 } from 'gl-matrix';
import {
clamp,
Circle,
clamp01,
id,
mix,
@ -12,6 +12,8 @@ import {
CharacterTeam,
PropertyUpdatesForObject,
UpdatePropertyCommand,
planetDistance,
planetGravity,
CommandExecutors,
CommandReceiver,
} from 'shared';
@ -20,6 +22,7 @@ import { AnnounceCommand } from '../commands/announce';
import { StepCommand } from '../commands/step';
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
import { StaticPhysical } from '../physics/physicals/static-physical';
import { LampPhysical } from './lamp-physical';
import type { CharacterPhysical } from './character-physical';
@ -85,59 +88,26 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
}
public distance(target: vec2): number {
// Evaluate the SDF in the planet's own rotating frame so this collision
// outline turns in lockstep with the rendered planet (see planet-shape.ts).
const local = this.toLocalFrame(target);
const startEnd = this.vertices[0];
let vb = startEnd;
let d = vec2.dist(local, vb);
let sign = 1;
for (let i = 1; i <= this.vertices.length; i++) {
const va = vb;
vb = i === this.vertices.length ? startEnd : this.vertices[i];
const targetFromDelta = vec2.subtract(vec2.create(), local, va);
const toFromDelta = vec2.subtract(vec2.create(), vb, va);
const h = clamp01(
vec2.dot(targetFromDelta, toFromDelta) / vec2.squaredLength(toFromDelta),
// The one shared planet outline — the same function the client predictor
// collides against, which is what lets prediction reconcile. Only the
// cos/sin memo lives here, because distance() is called many times per tick
// by the raymarcher and the angle rarely changes between those calls.
this.syncRotationTrigonometry();
return planetDistance(
target,
this.vertices,
this.center,
this.cosRotation,
this.sinRotation,
);
const ds = vec2.fromValues(
vec2.dist(targetFromDelta, vec2.scale(vec2.create(), toFromDelta, h)),
toFromDelta.x * targetFromDelta.y - toFromDelta.y * targetFromDelta.x,
);
if (
(local.y >= va.y && local.y < vb.y && ds.y > 0) ||
(local.y < va.y && local.y >= vb.y && ds.y <= 0)
) {
sign *= -1;
}
d = Math.min(d, ds.x);
}
return sign * d;
}
// Rotate a world point by -rotation about the centre, matching the shader's
// `localTarget = center + R(rotation) * (target - center)` transform exactly.
private toLocalFrame(target: vec2): vec2 {
private syncRotationTrigonometry() {
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
@ -146,14 +116,6 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
return this.rotationSpeed;
}
public get team(): CharacterTeam {
return Math.abs(this.ownership - 0.5) < settings.planetControlThreshold
? CharacterTeam.neutral
: this.ownership < 0.5
? CharacterTeam.blue
: CharacterTeam.red;
}
private timeSinceLastPointGeneration = 0;
private getPoints(game: CommandReceiver) {
if (this.timeSinceLastPointGeneration > settings.planetPointGenerationInterval) {
@ -317,32 +279,22 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
0,
);
this._boundingBox = new ImmutableBoundingBox(
this.center.x - maxVertexDistance,
this.center.x + maxVertexDistance,
this.center.y - maxVertexDistance,
this.center.y + maxVertexDistance,
this._boundingBox = getBoundingBoxOfCircle(
new Circle(this.center, maxVertexDistance),
);
}
return this._boundingBox;
}
public getForce(position: vec2): vec2 {
const diff = vec2.subtract(vec2.create(), this.center, position);
const dist = Math.max(settings.minGravityDistance, vec2.length(diff) - this.radius);
vec2.normalize(diff, diff);
const scale = clamp(
settings.maxGravityQ * ((settings.maxGravityDistance / dist) ** 1.5 - 1),
0,
settings.maxGravityStrength,
);
return vec2.scale(diff, diff, scale);
// GroundSurface gravity, shared with the client predictor.
public gravityAt(position: vec2): vec2 {
return planetGravity(this.center, this.radius, position);
}
// GroundSurface alias the shared movement simulation calls for gravity.
public gravityAt(position: vec2): vec2 {
return this.getForce(position);
// forceAtPosition's name for the same thing.
public getForce(position: vec2): vec2 {
return this.gravityAt(position);
}
public get gameObject(): this {

View file

@ -11,7 +11,7 @@ import {
Circle,
marchCircle,
} from 'shared';
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base';
import { CirclePhysical } from './circle-physical';
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
import { PhysicalContainer } from '../physics/containers/physical-container';
@ -28,7 +28,6 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
private isDestroyed = false;
private bounceCount = 0;
private _boundingBox?: ImmutableBoundingBox;
public object: CirclePhysical;
@ -107,12 +106,11 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
}
}
public get boundingBox(): ImmutableBoundingBox {
if (!this._boundingBox) {
this._boundingBox = (this.object as CirclePhysical).boundingBox;
}
return this._boundingBox;
// The circle keeps its own box in sync as it moves, so this is a live view of
// it, not a snapshot — caching it saved nothing and mistyped a mutable box as
// immutable.
public get boundingBox(): BoundingBoxBase {
return this.object.boundingBox;
}
public get gameObject(): this {
@ -163,22 +161,18 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
// This single broadphase is reused for the step below: the gravity radius
// (maxGravityDistance) dwarfs one tick's travel, so the set is a superset of
// the swept-collision box and the step needn't query the container again.
const intersecting = settings.projectileGravityEnabled
? this.container.findIntersecting(
const intersecting = this.container.findIntersecting(
getBoundingBoxOfCircle(
new Circle(this.center, this.object.radius + settings.maxGravityDistance),
),
)
: undefined;
);
if (intersecting) {
vec2.scaleAndAdd(
this.velocity,
this.velocity,
forceAtPosition(this.center, intersecting),
settings.projectileGravityScale * deltaTimeInSeconds,
);
}
vec2.copy(this.object.velocity, this.velocity);
const { velocity } = this.object.stepManually(deltaTimeInSeconds, intersecting);

View file

@ -1,5 +1,3 @@
import { vec2 } from 'gl-matrix';
// axis-aligned
export abstract class BoundingBoxBase {
constructor(
@ -43,14 +41,6 @@ export abstract class BoundingBoxBase {
return this._yMax;
}
public get topLeft(): vec2 {
return vec2.fromValues(this._xMin, this._yMax);
}
public get size(): vec2 {
return vec2.fromValues(this._xMax - this._xMin, this._yMax - this._yMin);
}
public intersects(other: BoundingBoxBase): boolean {
return (
this._xMin < other._xMax &&

View file

@ -1,6 +1,5 @@
import { vec2 } from 'gl-matrix';
import { BoundingBoxBase } from './bounding-box-base';
import { ImmutableBoundingBox } from './immutable-bounding-box';
export class BoundingBox extends BoundingBoxBase {
public get xMin(): number {
@ -52,8 +51,4 @@ export class BoundingBox extends BoundingBoxBase {
public get size(): vec2 {
return vec2.fromValues(this._xMax - this._xMin, this._yMax - this._yMin);
}
public cloneAsImmutable(): ImmutableBoundingBox {
return new ImmutableBoundingBox(this.xMin, this.xMax, this.yMin, this.yMax);
}
}

View file

@ -1,8 +1,7 @@
import { Circle } from 'shared';
import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base';
import { ImmutableBoundingBox } from '../bounding-boxes/immutable-bounding-box';
export const getBoundingBoxOfCircle = (circle: Circle): BoundingBoxBase =>
export const getBoundingBoxOfCircle = (circle: Circle): ImmutableBoundingBox =>
new ImmutableBoundingBox(
circle.center.x - circle.radius,
circle.center.x + circle.radius,

View file

@ -7,6 +7,7 @@ import {
MoveActionCommand,
CharacterTeam,
Id,
rotate90Deg,
} from 'shared';
import { PhysicalContainer } from '../physics/containers/physical-container';
import { PlayerContainer } from './player-container';
@ -100,23 +101,9 @@ export class NPC extends PlayerBase {
this.step(0);
}
private timeUntilRespawn = 0;
public step(deltaTimeInSeconds: number) {
if (this.character) {
this.center = this.character.center;
if (!this.character.isAlive) {
this.sumDeaths++;
this.sumKills = this.character.killCount;
this.character = null;
this.timeUntilRespawn = settings.playerDiedTimeout;
return;
}
} else {
if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) {
this.createCharacter();
this.center = this.character!.center;
}
const character = this.stepLifecycle(deltaTimeInSeconds);
if (!character) {
return;
}
@ -143,16 +130,16 @@ export class NPC extends PlayerBase {
this.dodgeCommitRemaining -= deltaTimeInSeconds;
this.dodgeCooldownRemaining -= deltaTimeInSeconds;
const movement = this.decideMovement(this.nearObjects);
this.character.handleMovementAction(new MoveActionCommand(movement));
character.handleMovementAction(new MoveActionCommand(movement));
if (
!this.isComingBack &&
this.character.groundPlanet &&
character.groundPlanet &&
vec2.length(movement) > 0 &&
Random.getRandom() <
npcTuning.leapChancePerSecond * this.aggression * deltaTimeInSeconds
) {
this.character.leap();
character.leap();
}
if (
@ -178,18 +165,15 @@ export class NPC extends PlayerBase {
const nearObjects = this.observe(npcTuning.planScanRadius);
const enemies = this.enemiesByDistance(nearObjects);
if (enemies.length > 0) {
const nearest = enemies[0];
if (nearest) {
const fleeRange =
npcTuning.fleeBaseRange * (npcTuning.fleeAggressionFalloff - this.aggression);
if (nearest.distance < fleeRange) {
vec2.subtract(this.direction, this.center, nearest.character.center);
return;
}
}
if (enemies.length > 0) {
const nearest = enemies[0];
const chaseRange =
npcTuning.chaseBaseRange + npcTuning.chaseAggressionRange * this.aggression;
if (nearest.distance < chaseRange) {
@ -275,7 +259,7 @@ export class NPC extends PlayerBase {
return undefined;
}
const perpendicular = vec2.fromValues(-threat.direction.y, threat.direction.x);
const perpendicular = rotate90Deg(threat.direction);
const toMe = vec2.subtract(vec2.create(), this.center, threat.center);
if (vec2.dot(perpendicular, toMe) < 0) {
vec2.negate(perpendicular, perpendicular);

View file

@ -7,6 +7,7 @@ import {
Random,
settings,
sanitizeName,
boundRadius,
} from 'shared';
import { PhysicalContainer } from '../physics/containers/physical-container';
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
@ -23,6 +24,7 @@ export abstract class PlayerBase extends CommandReceiver {
protected sumKills = 0;
protected sumDeaths = 0;
protected timeUntilRespawn = 0;
constructor(
protected readonly playerInfo: PlayerInformation,
@ -53,6 +55,44 @@ export abstract class PlayerBase extends CommandReceiver {
public abstract step(deltaTimeInSeconds: number): void;
/**
* The death/respawn lifecycle both a connected player and an NPC go through:
* bank the score off a body that just died, hold the corpse for the respawn
* timeout, then build a new one. Returns whether there is a living character
* to act with this tick — returned as the character itself, so a subclass can
* `const character = this.stepLifecycle(dt); if (!character) return;` and go
* on to use it without re-narrowing.
*/
protected stepLifecycle(deltaTimeInSeconds: number): CharacterPhysical | undefined {
if (this.character) {
this.center = this.character.center;
if (this.character.isAlive) {
return this.character;
}
this.sumDeaths++;
this.sumKills = this.character.killCount;
this.onCharacterDied(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;
}
// Hooks for what a subclass wants to do with the body it just lost, and just
// before a new one is built. Both are 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(
@ -96,10 +136,7 @@ export abstract class PlayerBase extends CommandReceiver {
radius * Math.sin(rotation) + preferredCenter.y,
);
const playerBoundingCircle = new Circle(
playerPosition,
CharacterPhysical.boundRadius,
);
const playerBoundingCircle = new Circle(playerPosition, boundRadius);
const playerBoundingBox = getBoundingBoxOfCircle(playerBoundingCircle);
const possibleIntersectors =

View file

@ -135,7 +135,11 @@ export class PlayerContainer {
}
public step(deltaTimeInSeconds: number) {
this.players.forEach((p) => p.step(deltaTimeInSeconds));
// Iterates the two backing arrays rather than the merged `players` getter:
// this runs once per 200 Hz physics substep, and the getter builds a new
// array every call.
this._players.forEach((p) => p.step(deltaTimeInSeconds));
this._npcs.forEach((p) => p.step(deltaTimeInSeconds));
}
public stepCommunication(deltaTimeInSeconds: number) {

View file

@ -52,9 +52,11 @@ const maximumBufferedBytes = settings.maxBufferedBytesPerClient;
export class Player extends PlayerBase {
// default, until the clients sends its real value
private aspectRatio: number = 16 / 9;
private timeUntilRespawn = 0;
private timeSinceLastMessage = 0;
private objectsPreviouslyInViewArea: Array<GameObject> = [];
// What this client has already been told about, in the order it was told.
// A Set because the per-snapshot diff below is membership-heavy; iteration
// order is insertion order, so the streamed object order is unchanged.
private objectsInViewArea = new Set<GameObject>();
private lastInputClientTimeMs = 0;
private lastInputReceiptMs = 0;
private lastLeapClientTimeMs = 0;
@ -190,7 +192,7 @@ export class Player extends PlayerBase {
protected createCharacter() {
super.createCharacter();
this.objectsPreviouslyInViewArea.push(this.character!);
this.objectsInViewArea.add(this.character!);
this.queueCommandSend(new CreatePlayerCommand(this.character!));
}
@ -205,27 +207,18 @@ export class Player extends PlayerBase {
private dyingCharacter?: CharacterPhysical | null;
public step(deltaTimeInSeconds: number) {
if (this.character) {
this.center = this.character?.center;
if (!this.character.isAlive) {
this.sumDeaths++;
this.sumKills = this.character.killCount;
this.dyingCharacter = this.character;
this.character = null;
this.timeUntilRespawn = settings.playerDiedTimeout;
this.stepLifecycle(deltaTimeInSeconds);
}
} else {
if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) {
protected onCharacterDied(character: CharacterPhysical) {
this.dyingCharacter = character;
}
protected onBeforeRespawn() {
if (this.dyingCharacter) {
this.sumKills = Math.max(this.sumKills, this.dyingCharacter.killCount);
this.dyingCharacter = null;
}
this.createCharacter();
this.center = this.character!.center;
}
}
}
private handleViewAreaUpdate() {
@ -234,26 +227,30 @@ export class Player extends PlayerBase {
bb.topLeft = viewArea.topLeft;
bb.size = viewArea.size;
const objectsInViewArea = Array.from(
new Set(this.objectContainer.findIntersecting(bb).map((o) => o.gameObject)),
const inViewArea = new Set(
this.objectContainer.findIntersecting(bb).map((o) => o.gameObject),
);
// The owning character must always be in its own snapshot, regardless of the
// view-area query, so the client predictor never loses its authoritative
// anchor (the body can ride a fast spinner to the very edge of the box).
if (this.character && !objectsInViewArea.includes(this.character)) {
objectsInViewArea.push(this.character);
if (this.character) {
inViewArea.add(this.character);
}
const newlyIntersecting = objectsInViewArea.filter(
(o) => !this.objectsPreviouslyInViewArea.includes(o),
// Set membership rather than Array.includes: this diff runs per player per
// snapshot over everything on their screen, and was quadratic in that.
// Set membership rather than Array.includes: this diff runs per player per
// snapshot over everything on their screen, and was quadratic in that.
const newlyIntersecting = [...inViewArea].filter(
(o) => !this.objectsInViewArea.has(o),
);
const noLongerIntersecting = this.objectsPreviouslyInViewArea.filter(
(o) => !objectsInViewArea.includes(o),
const noLongerIntersecting = [...this.objectsInViewArea].filter(
(o) => !inViewArea.has(o),
);
this.objectsPreviouslyInViewArea = objectsInViewArea;
this.objectsInViewArea = inViewArea;
if (noLongerIntersecting.length > 0) {
this.queueCommandSend(
@ -267,13 +264,15 @@ export class Player extends PlayerBase {
this.queueCommandSend(new UpdateMinimap(this.getMinimapPlayers()));
const propertyUpdates: Array<PropertyUpdatesForObject> = [];
for (const object of this.objectsInViewArea) {
const update = object.getPropertyUpdatesForFrame();
if (update) {
propertyUpdates.push(update);
}
}
this.queueCommandSend(
new PropertyUpdatesForObjects(
this.objectsPreviouslyInViewArea
.map((o) => o.getPropertyUpdatesForFrame())
.filter((u) => u) as Array<PropertyUpdatesForObject>,
performance.now() / 1000,
),
new PropertyUpdatesForObjects(propertyUpdates, performance.now() / 1000),
);
// Tell the client how much of its own input is reflected in the snapshot it
@ -310,11 +309,17 @@ export class Player extends PlayerBase {
}
public stepCommunications(deltaTime: number) {
const remoteCalls = this.objectsPreviouslyInViewArea
.map((g) => new RemoteCallsForObject(g.id, g.getRemoteCalls()))
.filter((c) => c.calls.length > 0);
// Runs at the physics rate, and on most ticks nothing has fired — so 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.length > 0) {
if (remoteCalls) {
this.queueCommandSend(new RemoteCallsForObjects(remoteCalls));
}

View file

@ -129,7 +129,7 @@ const main = async () => {
});
window.onpopstate = () => game.destroy();
for (; ;) {
for (;;) {
show(spinner);
hide(logoutButton, true);
show(landingUI, true, 'flex');

View file

@ -295,7 +295,7 @@ export class TouchListener extends CommandGenerator {
);
};
public update(_deltaTimeInSeconds: number) {
public update() {
if (!this.fireButton.parentElement) {
this.overlay.appendChild(this.fireButton);
}
@ -305,7 +305,8 @@ export class TouchListener extends CommandGenerator {
const character = this.game.gameObjects.player;
if (character) {
this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${character.strengthFraction * 360
this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${
character.strengthFraction * 360
}deg, transparent 0deg)`;
}
}

View file

@ -20,16 +20,6 @@ const isDevelopment =
typeof location !== 'undefined' &&
(location.hostname === 'localhost' || location.hostname === '127.0.0.1');
const servers: Array<string> = isDevelopment
export const servers: Array<string> = isDevelopment
? [`http://${location.hostname}:3000`, ...productionServers]
: productionServers;
export abstract class Configuration {
public static async initialize(): Promise<void> {
// Kept async for call-site compatibility; the server list is static now.
}
public static get servers(): Array<string> {
return servers;
}
}

View file

@ -384,7 +384,7 @@ export class Game extends CommandReceiver {
new RenderCommand(this.renderer, this.overlay, shouldChangeLayout),
);
this.touchListener.update(deltaTime);
this.touchListener.update();
this.tutorial.step(this.gameObjects);
@ -406,8 +406,10 @@ export class Game extends CommandReceiver {
this.handleKeystoneArrow();
if (this.announcementText.innerHTML !== this.lastAnnouncementText) {
this.announcementText.innerHTML = this.lastAnnouncementText;
}
}
// Points an off-screen chevron toward the keystone "Heart" planet, tinted by
// who currently holds it, so the match's focal objective is always findable.
@ -438,14 +440,7 @@ export class Game extends CommandReceiver {
display.y >= margin &&
display.y <= height - margin;
const control = Math.abs(keystone.ownership - 0.5);
const team =
control < settings.planetControlThreshold
? 'neutral'
: keystone.ownership < 0.5
? 'blue'
: 'red';
this.keystoneArrow.className = 'keystone-arrow ' + team;
this.keystoneArrow.className = 'keystone-arrow ' + keystone.team;
if (onScreen) {
this.keystoneArrow.style.display = 'none';

View file

@ -14,8 +14,11 @@ import {
import { ClientCharacterWorld, PredictablePlanet } from './client-character-world';
import { InputHistory } from './input-history';
const stepMs = 1000 / 200; // match the server's 200 Hz fixed tick
const stepSeconds = 1 / 200;
// The server's fixed tick, read from the same place the server reads it — the
// replay integrates over this dt, so a comment promising they match is not
// enough.
const stepSeconds = settings.targetPhysicsDeltaTimeInSeconds;
const stepMs = stepSeconds * 1000;
// The client's clock: the timestamp of the frame currently being rendered, set
// once per frame by the game loop (requestAnimationFrame hands it to us

View file

@ -1,6 +1,6 @@
import { ServerInformation, serverInformationEndpoint, TransportEvents } from 'shared';
import { io, Socket } from 'socket.io-client';
import { Configuration } from './configuration';
import { servers } from './configuration';
import parser from 'socket.io-msgpack-parser';
import { SoundHandler, Sounds } from './sound-handler';
@ -69,11 +69,7 @@ export class JoinFormHandler {
private servers: Array<ServerChooserOption> = [];
private async loadServers() {
await Configuration.initialize();
const serverList = Configuration.servers.filter(
(u) => !this.servers.find((s) => s.url === u),
);
const serverList = servers.filter((u) => !this.servers.find((s) => s.url === u));
serverList.map(async (url) => {
const controller = new AbortController();

View file

@ -2,6 +2,7 @@ import { vec2, vec3 } from 'gl-matrix';
import { CircleLight, Renderer } from 'sdf-2d';
import {
characterCenter,
Circle,
Id,
CharacterBase,
@ -10,6 +11,7 @@ import {
clamp,
clamp01,
mix,
strengthToCharge,
CommandExecutors,
UpdatePropertyCommand,
} from 'shared';
@ -123,9 +125,7 @@ export class CharacterView extends CharacterBase {
}
public get bodyCenter(): vec2 {
const center = vec2.add(vec2.create(), this.head!.center, this.leftFoot!.center);
vec2.add(center, center, this.rightFoot!.center);
return vec2.scale(center, center, 1 / 3);
return characterCenter(this.head!, this.leftFoot!, this.rightFoot!);
}
public get facingDirection(): vec2 {
@ -271,10 +271,7 @@ export class CharacterView extends CharacterBase {
}
public onShoot(strength: number) {
const q = clamp01(
(strength - settings.chargeShotStrengthMin) /
(settings.chargeShotStrengthMax - settings.chargeShotStrengthMin),
);
const q = strengthToCharge(strength);
SoundHandler.play(Sounds.shoot, mix(0.55, 1, q), mix(1.15, 0.8, q));
this.muzzleFlashIntensity = mix(0.35, 1, q);
}
@ -332,16 +329,11 @@ export class CharacterView extends CharacterBase {
}
private calculateTextPosition(): vec2 {
const footAverage = vec2.add(
return vec2.scaleAndAdd(
vec2.create(),
this.leftFoot!.center,
this.rightFoot!.center,
this.head!.center,
this.facingDirection,
this.head!.radius + 80,
);
vec2.scale(footAverage, footAverage, 0.5);
const headFeetDelta = vec2.subtract(footAverage, this.head!.center, footAverage);
vec2.normalize(headFeetDelta, headFeetDelta);
const textOffset = vec2.scale(headFeetDelta, headFeetDelta, this.head!.radius + 80);
return vec2.add(textOffset, this.head!.center, textOffset);
}
}

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