Modernise & make fun #3
23 changed files with 408 additions and 236 deletions
9
backend/src/commands/announce.ts
Normal file
9
backend/src/commands/announce.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
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 { LampPhysical } from './objects/lamp-physical';
|
||||||
import { PlanetPhysical } from './objects/planet-physical';
|
import { PlanetPhysical } from './objects/planet-physical';
|
||||||
import { PhysicalContainer } from './physics/containers/physical-container';
|
import { PhysicalContainer } from './physics/containers/physical-container';
|
||||||
import { evaluateSdf } from './physics/functions/evaluate-sdf';
|
|
||||||
import { Physical } from './physics/physicals/physical';
|
import { Physical } from './physics/physicals/physical';
|
||||||
|
|
||||||
export const createWorld = (objectContainer: PhysicalContainer) => {
|
export const createWorld = (objectContainer: PhysicalContainer) => {
|
||||||
|
|
@ -45,13 +44,16 @@ export const createWorld = (objectContainer: PhysicalContainer) => {
|
||||||
) {
|
) {
|
||||||
const planet =
|
const planet =
|
||||||
objects.length === 0
|
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(
|
PlanetBase.createPlanetVertices(
|
||||||
position,
|
position,
|
||||||
Random.getRandomInRange(1600, 2400),
|
Random.getRandomInRange(1600, 2400),
|
||||||
Random.getRandomInRange(1600, 2400),
|
Random.getRandomInRange(1600, 2400),
|
||||||
Random.getRandomInRange(80, 300),
|
Random.getRandomInRange(80, 300),
|
||||||
),
|
),
|
||||||
|
true,
|
||||||
)
|
)
|
||||||
: new PlanetPhysical(
|
: new PlanetPhysical(
|
||||||
PlanetBase.createPlanetVertices(
|
PlanetBase.createPlanetVertices(
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { Options } from './options';
|
||||||
import { PlayerContainer } from './players/player-container';
|
import { PlayerContainer } from './players/player-container';
|
||||||
import { StepCommand } from './commands/step';
|
import { StepCommand } from './commands/step';
|
||||||
import { GeneratePointsCommand } from './commands/generate-points';
|
import { GeneratePointsCommand } from './commands/generate-points';
|
||||||
|
import { AnnounceCommand } from './commands/announce';
|
||||||
|
|
||||||
const gameStateSubscribedRoom = 'gameStateSubscribedRoom';
|
const gameStateSubscribedRoom = 'gameStateSubscribedRoom';
|
||||||
|
|
||||||
|
|
@ -63,6 +64,8 @@ export class GameServer extends CommandReceiver {
|
||||||
|
|
||||||
protected commandExecutors: CommandExecutors = {
|
protected commandExecutors: CommandExecutors = {
|
||||||
[GeneratePointsCommand.type]: this.addPoints.bind(this),
|
[GeneratePointsCommand.type]: this.addPoints.bind(this),
|
||||||
|
[AnnounceCommand.type]: ({ text }: AnnounceCommand) =>
|
||||||
|
this.players.queueCommandForEachClient(new ServerAnnouncement(text)),
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
|
@ -164,6 +167,7 @@ export class GameServer extends CommandReceiver {
|
||||||
}
|
}
|
||||||
|
|
||||||
private timeSinceLastPointUpdate = 0;
|
private timeSinceLastPointUpdate = 0;
|
||||||
|
private physicsAccumulator = 0;
|
||||||
|
|
||||||
private handlePhysics() {
|
private handlePhysics() {
|
||||||
const delta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds({ setAsBase: true });
|
const delta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds({ setAsBase: true });
|
||||||
|
|
@ -183,14 +187,28 @@ export class GameServer extends CommandReceiver {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let scaledDelta = delta;
|
const fixedDelta = settings.targetPhysicsDeltaTimeInSeconds;
|
||||||
if (this.isInEndGame) {
|
const maxSubstepsPerFrame = 5;
|
||||||
this.timeScaling *= Math.pow(settings.endGameDeltaScaling, delta);
|
|
||||||
scaledDelta /= this.timeScaling;
|
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.objects.handleCommand(new StepCommand(scaledDelta, this));
|
||||||
this.players.step(scaledDelta);
|
this.players.step(scaledDelta);
|
||||||
|
}
|
||||||
|
|
||||||
this.players.stepCommunication(delta);
|
this.players.stepCommunication(delta);
|
||||||
this.objects.resetRemoteCalls();
|
this.objects.resetRemoteCalls();
|
||||||
|
|
||||||
|
|
@ -198,7 +216,7 @@ export class GameServer extends CommandReceiver {
|
||||||
|
|
||||||
setTimeout(
|
setTimeout(
|
||||||
this.handlePhysics.bind(this),
|
this.handlePhysics.bind(this),
|
||||||
Math.max(0, settings.targetPhysicsDeltaTimeInSeconds - physicsDelta) * 1000,
|
Math.max(0, fixedDelta - physicsDelta) * 1000,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
};
|
|
||||||
|
|
@ -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
|
// 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
|
// collision SDF use, so the player is carried around and turned with the
|
||||||
// surface instead of it sliding frictionlessly underneath. The positional
|
// surface instead of it sliding frictionlessly underneath. The positional
|
||||||
// change becomes velocity in setPropertyUpdates, so the motion syncs and the
|
// change becomes velocity in setPropertyUpdates, keeping the streamed
|
||||||
// client extrapolates it smoothly.
|
// rate-of-change consistent with the streamed positions.
|
||||||
private carryWithRotatingPlanet(deltaTimeInSeconds: number) {
|
private carryWithRotatingPlanet(deltaTimeInSeconds: number) {
|
||||||
const planet = this.currentPlanet;
|
const planet = this.currentPlanet;
|
||||||
if (!planet) {
|
if (!planet) {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
clamp01,
|
clamp01,
|
||||||
id,
|
id,
|
||||||
mix,
|
mix,
|
||||||
|
Random,
|
||||||
serializesTo,
|
serializesTo,
|
||||||
settings,
|
settings,
|
||||||
PlanetBase,
|
PlanetBase,
|
||||||
|
|
@ -28,6 +29,16 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
||||||
|
|
||||||
public readonly sizePointMultiplier: number;
|
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 _boundingBox?: ImmutableBoundingBox;
|
||||||
|
|
||||||
private readonly lamps: Array<LampPhysical> = [];
|
private readonly lamps: Array<LampPhysical> = [];
|
||||||
|
|
@ -51,19 +62,26 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
||||||
);
|
);
|
||||||
|
|
||||||
this.sizePointMultiplier = mix(1, settings.planetSizePointMultiplierMax, sizeClass);
|
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 {
|
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];
|
const startEnd = this.vertices[0];
|
||||||
let vb = startEnd;
|
let vb = startEnd;
|
||||||
|
|
||||||
let d = vec2.squaredDistance(target, vb);
|
let d = vec2.dist(local, vb);
|
||||||
let sign = 1;
|
let sign = 1;
|
||||||
|
|
||||||
for (let i = 1; i <= this.vertices.length; i++) {
|
for (let i = 1; i <= this.vertices.length; i++) {
|
||||||
const va = vb;
|
const va = vb;
|
||||||
vb = i === this.vertices.length ? startEnd : this.vertices[i];
|
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 toFromDelta = vec2.subtract(vec2.create(), vb, va);
|
||||||
const h = clamp01(
|
const h = clamp01(
|
||||||
vec2.dot(targetFromDelta, toFromDelta) / vec2.squaredLength(toFromDelta),
|
vec2.dot(targetFromDelta, toFromDelta) / vec2.squaredLength(toFromDelta),
|
||||||
|
|
@ -75,8 +93,8 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(target.y >= va.y && target.y < vb.y && ds.y > 0) ||
|
(local.y >= va.y && local.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)
|
||||||
) {
|
) {
|
||||||
sign *= -1;
|
sign *= -1;
|
||||||
}
|
}
|
||||||
|
|
@ -87,11 +105,35 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
||||||
return sign * d;
|
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 {
|
public get team(): CharacterTeam {
|
||||||
return Math.abs(this.ownership - 0.5) < 0.1
|
return Math.abs(this.ownership - 0.5) < 0.1
|
||||||
? CharacterTeam.neutral
|
? CharacterTeam.neutral
|
||||||
: this.ownership < 0.5
|
: this.ownership < 0.5
|
||||||
? CharacterTeam.decla
|
? CharacterTeam.blue
|
||||||
: CharacterTeam.red;
|
: CharacterTeam.red;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -105,7 +147,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
||||||
);
|
);
|
||||||
game.handleCommand(
|
game.handleCommand(
|
||||||
new GeneratePointsCommand(
|
new GeneratePointsCommand(
|
||||||
this.team === CharacterTeam.decla ? value : 0,
|
this.team === CharacterTeam.blue ? value : 0,
|
||||||
this.team === CharacterTeam.red ? value : 0,
|
this.team === CharacterTeam.red ? value : 0,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -113,6 +155,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
||||||
}
|
}
|
||||||
|
|
||||||
private step({ deltaTimeInSeconds, game }: StepCommand) {
|
private step({ deltaTimeInSeconds, game }: StepCommand) {
|
||||||
|
this.rotation += deltaTimeInSeconds * this.rotationSpeed;
|
||||||
this.timeSinceLastPointGeneration += deltaTimeInSeconds;
|
this.timeSinceLastPointGeneration += deltaTimeInSeconds;
|
||||||
|
|
||||||
// In reverse order, so that teams can achieve a 100% control.
|
// 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);
|
this.remoteCall('generatedPoints', reward);
|
||||||
game.handleCommand(
|
game.handleCommand(
|
||||||
new GeneratePointsCommand(
|
new GeneratePointsCommand(
|
||||||
currentTeam === CharacterTeam.decla ? reward : 0,
|
currentTeam === CharacterTeam.blue ? reward : 0,
|
||||||
currentTeam === CharacterTeam.red ? reward : 0,
|
currentTeam === CharacterTeam.red ? reward : 0,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -153,11 +196,14 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
||||||
public getPropertyUpdates(): PropertyUpdatesForObject {
|
public getPropertyUpdates(): PropertyUpdatesForObject {
|
||||||
return new PropertyUpdatesForObject(this.id, [
|
return new PropertyUpdatesForObject(this.id, [
|
||||||
new UpdatePropertyCommand('ownership', this.ownership, 0),
|
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) {
|
public takeControl(team: CharacterTeam, deltaTime: number) {
|
||||||
if (team === CharacterTeam.decla) {
|
if (team === CharacterTeam.blue) {
|
||||||
this.ownership -= (0.5 / settings.takeControlTimeInSeconds) * deltaTime;
|
this.ownership -= (0.5 / settings.takeControlTimeInSeconds) * deltaTime;
|
||||||
} else if (team === CharacterTeam.red) {
|
} else if (team === CharacterTeam.red) {
|
||||||
this.ownership += (0.5 / settings.takeControlTimeInSeconds) * deltaTime;
|
this.ownership += (0.5 / settings.takeControlTimeInSeconds) * deltaTime;
|
||||||
|
|
@ -180,22 +226,20 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
|
||||||
|
|
||||||
public get boundingBox(): ImmutableBoundingBox {
|
public get boundingBox(): ImmutableBoundingBox {
|
||||||
if (!this._boundingBox) {
|
if (!this._boundingBox) {
|
||||||
const { xMin, xMax, yMin, yMax } = this.vertices.reduce(
|
// The polygon spins about its centre (see distance), so this static box
|
||||||
(extremities, vertex) => ({
|
// has to cover every orientation, not just the spawn-time one: take the
|
||||||
xMin: Math.min(extremities.xMin, vertex.x),
|
// circumscribed circle around the rotation centre.
|
||||||
xMax: Math.max(extremities.xMax, vertex.x),
|
const maxVertexDistance = this.vertices.reduce(
|
||||||
yMin: Math.min(extremities.yMin, vertex.y),
|
(max, vertex) => Math.max(max, vec2.distance(this.center, vertex)),
|
||||||
yMax: Math.max(extremities.yMax, vertex.y),
|
0,
|
||||||
}),
|
|
||||||
{
|
|
||||||
xMin: Infinity,
|
|
||||||
xMax: -Infinity,
|
|
||||||
yMin: Infinity,
|
|
||||||
yMax: -Infinity,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
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;
|
return this._boundingBox;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { performance } from 'perf_hooks';
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2 } from 'gl-matrix';
|
||||||
import {
|
import {
|
||||||
CommandExecutors,
|
CommandExecutors,
|
||||||
|
|
@ -126,6 +127,7 @@ export class Player extends PlayerBase {
|
||||||
this.objectsPreviouslyInViewArea
|
this.objectsPreviouslyInViewArea
|
||||||
.map((o) => o.getPropertyUpdates())
|
.map((o) => o.getPropertyUpdates())
|
||||||
.filter((u) => u) as Array<PropertyUpdatesForObject>,
|
.filter((u) => u) as Array<PropertyUpdatesForObject>,
|
||||||
|
performance.now() / 1000,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import { CharacterShape } from './shapes/character-shape';
|
||||||
import { PlanetShape } from './shapes/planet-shape';
|
import { PlanetShape } from './shapes/planet-shape';
|
||||||
import { RenderCommand } from './commands/types/render';
|
import { RenderCommand } from './commands/types/render';
|
||||||
import { StepCommand } from './commands/types/step';
|
import { StepCommand } from './commands/types/step';
|
||||||
|
import { serverTimeline } from './helper/server-timeline';
|
||||||
import { Tutorial } from './tutorial';
|
import { Tutorial } from './tutorial';
|
||||||
import { Scoreboard } from './scoreboard';
|
import { Scoreboard } from './scoreboard';
|
||||||
|
|
||||||
|
|
@ -74,6 +75,7 @@ export class Game extends CommandReceiver {
|
||||||
this.isBetweenGames = true;
|
this.isBetweenGames = true;
|
||||||
|
|
||||||
this.socket?.close();
|
this.socket?.close();
|
||||||
|
serverTimeline.reset();
|
||||||
this.gameObjects = new GameObjectContainer(this);
|
this.gameObjects = new GameObjectContainer(this);
|
||||||
this.overlay.innerHTML = '';
|
this.overlay.innerHTML = '';
|
||||||
this.arrows = {};
|
this.arrows = {};
|
||||||
|
|
@ -274,6 +276,11 @@ export class Game extends CommandReceiver {
|
||||||
this.resolveStarted();
|
this.resolveStarted();
|
||||||
deltaTime /= 1000;
|
deltaTime /= 1000;
|
||||||
|
|
||||||
|
// Stepped before the end-game time scaling on purpose: the slow motion is
|
||||||
|
// already baked into the snapshots the server sends, so the playback
|
||||||
|
// cursor itself must keep running on wall-clock time.
|
||||||
|
serverTimeline.step(deltaTime);
|
||||||
|
|
||||||
let shouldChangeLayout = false;
|
let shouldChangeLayout = false;
|
||||||
if (++this.framesSinceLastLayoutUpdate > 1) {
|
if (++this.framesSinceLastLayoutUpdate > 1) {
|
||||||
shouldChangeLayout = true;
|
shouldChangeLayout = true;
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
export class LinearExtrapolator {
|
|
||||||
private velocity = 0;
|
|
||||||
private compensationVelocity = 0;
|
|
||||||
private timeSinceSet = 0;
|
|
||||||
|
|
||||||
constructor(private currentValue: number) {}
|
|
||||||
|
|
||||||
public addFrame(value: number, rateOfChange: number) {
|
|
||||||
this.timeSinceSet = 0;
|
|
||||||
const differenceFromCurrent = value - this.currentValue;
|
|
||||||
|
|
||||||
if (Math.abs(differenceFromCurrent) > 200) {
|
|
||||||
this.currentValue = value;
|
|
||||||
this.compensationVelocity = 0;
|
|
||||||
} else {
|
|
||||||
this.compensationVelocity = differenceFromCurrent / (1 / 30);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.velocity = rateOfChange;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getValue(deltaTime: number): number {
|
|
||||||
this.currentValue += deltaTime * this.velocity;
|
|
||||||
|
|
||||||
const compensationTimeLeft = 1 / 30 - this.timeSinceSet;
|
|
||||||
|
|
||||||
if (compensationTimeLeft > 0) {
|
|
||||||
this.currentValue +=
|
|
||||||
Math.min(compensationTimeLeft, deltaTime) * this.compensationVelocity;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.timeSinceSet += deltaTime;
|
|
||||||
|
|
||||||
return this.currentValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import { Circle } from 'shared';
|
import { Circle } from 'shared';
|
||||||
import { LinearExtrapolator } from './linear-extrapolator';
|
import { LinearInterpolator } from './linear-interpolator';
|
||||||
import { Vec2Extrapolator } from './vec2-extrapolator';
|
import { Vec2Interpolator } from './vec2-interpolator';
|
||||||
|
|
||||||
export class CircleExtrapolator {
|
export class CircleInterpolator {
|
||||||
private center: Vec2Extrapolator;
|
private center: Vec2Interpolator;
|
||||||
private radius: LinearExtrapolator;
|
private radius: LinearInterpolator;
|
||||||
|
|
||||||
constructor(currentValue: Circle) {
|
constructor(currentValue: Circle) {
|
||||||
this.center = new Vec2Extrapolator(currentValue.center);
|
this.center = new Vec2Interpolator(currentValue.center);
|
||||||
this.radius = new LinearExtrapolator(currentValue.radius);
|
this.radius = new LinearInterpolator(currentValue.radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
public addFrame(value: Circle, rateOfChange: Circle) {
|
public addFrame(value: Circle, rateOfChange: Circle) {
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { last, mix } from 'shared';
|
||||||
|
import { serverTimeline } from '../server-timeline';
|
||||||
|
|
||||||
|
interface Frame {
|
||||||
|
time: number;
|
||||||
|
value: number;
|
||||||
|
velocity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// How far past the newest snapshot the value may coast on its last known
|
||||||
|
// velocity (network hiccup) before freezing in place.
|
||||||
|
const maxCoastSeconds = 0.06;
|
||||||
|
|
||||||
|
// When fresh snapshots arrive after a coast, they usually disagree with where
|
||||||
|
// the coast ended up; the difference decays away with this time constant
|
||||||
|
// instead of being shown as a jump.
|
||||||
|
const blendSeconds = 0.12;
|
||||||
|
|
||||||
|
// At 25 snapshots per second this spans over a second of state, far more than
|
||||||
|
// rendering ever needs; it only bounds memory while the tab is hidden and
|
||||||
|
// snapshots keep arriving without being consumed.
|
||||||
|
const maxFrames = 32;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replays a server-streamed scalar by interpolating between timestamped
|
||||||
|
* snapshots at the shared timeline's render time, which trails the newest
|
||||||
|
* snapshot. The streamed rate of change is only used to coast briefly when
|
||||||
|
* the buffer runs dry.
|
||||||
|
*/
|
||||||
|
export class LinearInterpolator {
|
||||||
|
private frames: Array<Frame> = [];
|
||||||
|
private blendOffset = 0;
|
||||||
|
private lastValue: number;
|
||||||
|
private isCoasting = false;
|
||||||
|
|
||||||
|
constructor(private readonly initialValue: number) {
|
||||||
|
this.lastValue = initialValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public addFrame(value: number, rateOfChange: number) {
|
||||||
|
const time = serverTimeline.snapshotTime;
|
||||||
|
const newest = last(this.frames);
|
||||||
|
const wasCoasting = this.isCoasting;
|
||||||
|
|
||||||
|
if (newest && time <= newest.time) {
|
||||||
|
this.frames[this.frames.length - 1] = {
|
||||||
|
time: newest.time,
|
||||||
|
value,
|
||||||
|
velocity: rateOfChange,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
this.frames.push({ time, value, velocity: rateOfChange });
|
||||||
|
if (this.frames.length > maxFrames) {
|
||||||
|
this.frames.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wasCoasting) {
|
||||||
|
// Continue from where the coast left off and let the offset decay,
|
||||||
|
// instead of jumping onto the freshly revealed trajectory.
|
||||||
|
this.blendOffset = this.lastValue - this.sample(serverTimeline.renderTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getValue(deltaTimeInSeconds: number): number {
|
||||||
|
this.blendOffset *= Math.exp(-deltaTimeInSeconds / blendSeconds);
|
||||||
|
return (this.lastValue = this.sample(serverTimeline.renderTime) + this.blendOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sample(time: number): number {
|
||||||
|
if (this.frames.length === 0) {
|
||||||
|
return this.initialValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (this.frames.length >= 2 && this.frames[1].time <= time) {
|
||||||
|
this.frames.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
const [current, next] = this.frames;
|
||||||
|
this.isCoasting = false;
|
||||||
|
|
||||||
|
if (time <= current.time) {
|
||||||
|
return current.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next) {
|
||||||
|
return mix(
|
||||||
|
current.value,
|
||||||
|
next.value,
|
||||||
|
(time - current.time) / (next.time - current.time),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isCoasting = true;
|
||||||
|
return (
|
||||||
|
current.value + current.velocity * Math.min(time - current.time, maxCoastSeconds)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2 } from 'gl-matrix';
|
||||||
import { LinearExtrapolator } from './linear-extrapolator';
|
import { LinearInterpolator } from './linear-interpolator';
|
||||||
|
|
||||||
export class Vec2Extrapolator {
|
export class Vec2Interpolator {
|
||||||
private x: LinearExtrapolator;
|
private x: LinearInterpolator;
|
||||||
private y: LinearExtrapolator;
|
private y: LinearInterpolator;
|
||||||
|
|
||||||
constructor(currentValue: vec2) {
|
constructor(currentValue: vec2) {
|
||||||
this.x = new LinearExtrapolator(currentValue.x);
|
this.x = new LinearInterpolator(currentValue.x);
|
||||||
this.y = new LinearExtrapolator(currentValue.y);
|
this.y = new LinearInterpolator(currentValue.y);
|
||||||
}
|
}
|
||||||
|
|
||||||
public addFrame(value: vec2, rateOfChange: vec2) {
|
public addFrame(value: vec2, rateOfChange: vec2) {
|
||||||
84
frontend/src/scripts/helper/server-timeline.ts
Normal file
84
frontend/src/scripts/helper/server-timeline.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { clamp, settings } from 'shared';
|
||||||
|
|
||||||
|
// Convergence rate of the playback cursor towards its target; a deviation
|
||||||
|
// decays with a time constant of 1 / rateGain seconds.
|
||||||
|
const rateGain = 2;
|
||||||
|
|
||||||
|
// Playback speed stays within [0.75, 1.25]× so corrections are invisible.
|
||||||
|
const maxRateAdjustment = 0.25;
|
||||||
|
|
||||||
|
// Beyond this divergence (tab was in the background, server changed) chasing
|
||||||
|
// the target is pointless: jump straight to it.
|
||||||
|
const resyncSeconds = 0.3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The playback clock for state streamed from the server.
|
||||||
|
*
|
||||||
|
* Snapshot timestamps estimate the server's clock: the newest received
|
||||||
|
* timestamp plus the time elapsed since it arrived. Rendering happens
|
||||||
|
* settings.interpolationDelaySeconds behind that estimate, so there is
|
||||||
|
* normally a newer snapshot to interpolate towards and network jitter is
|
||||||
|
* absorbed by the buffer instead of being shown.
|
||||||
|
*
|
||||||
|
* The cursor never jumps under normal operation: it runs at a gently adjusted
|
||||||
|
* rate to stay on target and only snaps after a long divergence.
|
||||||
|
*/
|
||||||
|
class ServerTimeline {
|
||||||
|
private cursor?: number;
|
||||||
|
private newestSnapshotTime?: number;
|
||||||
|
private sinceNewestSnapshot = 0;
|
||||||
|
private _snapshotTime = 0;
|
||||||
|
|
||||||
|
/** Timestamp of the update batch currently being applied. */
|
||||||
|
public get snapshotTime(): number {
|
||||||
|
return this._snapshotTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The point on the server's clock that should be rendered this frame. */
|
||||||
|
public get renderTime(): number {
|
||||||
|
return this.cursor ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public onSnapshot(timestamp: number) {
|
||||||
|
this._snapshotTime = timestamp;
|
||||||
|
if (this.newestSnapshotTime === undefined || timestamp > this.newestSnapshotTime) {
|
||||||
|
this.newestSnapshotTime = timestamp;
|
||||||
|
this.sinceNewestSnapshot = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be called with unscaled wall-clock time, even during the end-game
|
||||||
|
// slow motion: the slowdown is already baked into the snapshots.
|
||||||
|
public step(deltaTimeInSeconds: number) {
|
||||||
|
if (this.newestSnapshotTime === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sinceNewestSnapshot += deltaTimeInSeconds;
|
||||||
|
const target =
|
||||||
|
this.newestSnapshotTime +
|
||||||
|
this.sinceNewestSnapshot -
|
||||||
|
settings.interpolationDelaySeconds;
|
||||||
|
|
||||||
|
if (this.cursor === undefined || Math.abs(target - this.cursor) > resyncSeconds) {
|
||||||
|
this.cursor = target;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rate = clamp(
|
||||||
|
1 + (target - this.cursor) * rateGain,
|
||||||
|
1 - maxRateAdjustment,
|
||||||
|
1 + maxRateAdjustment,
|
||||||
|
);
|
||||||
|
this.cursor += deltaTimeInSeconds * rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public reset() {
|
||||||
|
this.cursor = undefined;
|
||||||
|
this.newestSnapshotTime = undefined;
|
||||||
|
this.sinceNewestSnapshot = 0;
|
||||||
|
this._snapshotTime = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const serverTimeline = new ServerTimeline();
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
import { BeforeDestroyCommand } from '../commands/types/before-destroy';
|
import { BeforeDestroyCommand } from '../commands/types/before-destroy';
|
||||||
import { StepCommand } from '../commands/types/step';
|
import { StepCommand } from '../commands/types/step';
|
||||||
import { Game } from '../game';
|
import { Game } from '../game';
|
||||||
|
import { serverTimeline } from '../helper/server-timeline';
|
||||||
import { Camera } from './types/camera';
|
import { Camera } from './types/camera';
|
||||||
import { CharacterView } from './types/character-view';
|
import { CharacterView } from './types/character-view';
|
||||||
import { PlanetView } from './types/planet-view';
|
import { PlanetView } from './types/planet-view';
|
||||||
|
|
@ -36,7 +37,7 @@ export class GameObjectContainer extends CommandReceiver {
|
||||||
this.defaultCommandExecutor(c);
|
this.defaultCommandExecutor(c);
|
||||||
|
|
||||||
if (this.player) {
|
if (this.player) {
|
||||||
this.camera.center = this.player.position;
|
this.camera.follow(this.player.position, c.deltaTimeInSeconds);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -45,10 +46,12 @@ export class GameObjectContainer extends CommandReceiver {
|
||||||
this.objects.get(c.id)?.processRemoteCalls(c.calls),
|
this.objects.get(c.id)?.processRemoteCalls(c.calls),
|
||||||
),
|
),
|
||||||
|
|
||||||
[PropertyUpdatesForObjects.type]: (c: PropertyUpdatesForObjects) =>
|
[PropertyUpdatesForObjects.type]: (c: PropertyUpdatesForObjects) => {
|
||||||
|
serverTimeline.onSnapshot(c.timestamp);
|
||||||
c.updates.forEach((u) =>
|
c.updates.forEach((u) =>
|
||||||
u.updates.forEach((au) => this.objects.get(u.id)?.handleCommand(au)),
|
u.updates.forEach((au) => this.objects.get(u.id)?.handleCommand(au)),
|
||||||
),
|
);
|
||||||
|
},
|
||||||
|
|
||||||
[DeleteObjectsCommand.type]: (c: DeleteObjectsCommand) =>
|
[DeleteObjectsCommand.type]: (c: DeleteObjectsCommand) =>
|
||||||
c.ids.forEach((id: Id) => this.deleteObject(id)),
|
c.ids.forEach((id: Id) => this.deleteObject(id)),
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,28 @@ export class Camera extends CommandReceiver {
|
||||||
public center: vec2 = vec2.create();
|
public center: vec2 = vec2.create();
|
||||||
private aspectRatio?: number;
|
private aspectRatio?: number;
|
||||||
|
|
||||||
|
// A short exponential lag masks any residual stepping in the followed
|
||||||
|
// position without the camera noticeably trailing during normal movement.
|
||||||
|
private static readonly followSeconds = 0.08;
|
||||||
|
|
||||||
|
// Swooshing across half the map after a respawn would be disorienting;
|
||||||
|
// beyond this distance the camera cuts instead.
|
||||||
|
private static readonly snapDistance = 1500;
|
||||||
|
|
||||||
constructor(private game: Game) {
|
constructor(private game: Game) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public follow(target: vec2, deltaTimeInSeconds: number) {
|
||||||
|
if (vec2.distance(target, this.center) > Camera.snapDistance) {
|
||||||
|
vec2.copy(this.center, target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = 1 - Math.exp(-deltaTimeInSeconds / Camera.followSeconds);
|
||||||
|
vec2.lerp(this.center, this.center, target, q);
|
||||||
|
}
|
||||||
|
|
||||||
protected commandExecutors: CommandExecutors = {
|
protected commandExecutors: CommandExecutors = {
|
||||||
[RenderCommand.type]: this.draw.bind(this),
|
[RenderCommand.type]: this.draw.bind(this),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ import {
|
||||||
import { BeforeDestroyCommand } from '../../commands/types/before-destroy';
|
import { BeforeDestroyCommand } from '../../commands/types/before-destroy';
|
||||||
import { RenderCommand } from '../../commands/types/render';
|
import { RenderCommand } from '../../commands/types/render';
|
||||||
import { StepCommand } from '../../commands/types/step';
|
import { StepCommand } from '../../commands/types/step';
|
||||||
import { CircleExtrapolator } from '../../helper/extrapolators/circle-extrapolator';
|
import { CircleInterpolator } from '../../helper/interpolators/circle-interpolator';
|
||||||
import { LinearExtrapolator } from '../../helper/extrapolators/linear-extrapolator';
|
import { LinearInterpolator } from '../../helper/interpolators/linear-interpolator';
|
||||||
import { Pointer } from '../../helper/pointer';
|
import { Pointer } from '../../helper/pointer';
|
||||||
import { CharacterShape } from '../../shapes/character-shape';
|
import { CharacterShape } from '../../shapes/character-shape';
|
||||||
import { SoundHandler, Sounds } from '../../sound-handler';
|
import { SoundHandler, Sounds } from '../../sound-handler';
|
||||||
|
|
@ -40,7 +40,7 @@ export class CharacterView extends CharacterBase {
|
||||||
private muzzleFlashIntensity = 0;
|
private muzzleFlashIntensity = 0;
|
||||||
private hitFlashIntensity = 0;
|
private hitFlashIntensity = 0;
|
||||||
private strength = settings.playerMaxStrength;
|
private strength = settings.playerMaxStrength;
|
||||||
private strengthExtrapolator = new LinearExtrapolator(settings.playerMaxStrength);
|
private strengthInterpolator = new LinearInterpolator(settings.playerMaxStrength);
|
||||||
private nameElement: HTMLElement = document.createElement('div');
|
private nameElement: HTMLElement = document.createElement('div');
|
||||||
private statsElement: HTMLElement = document.createElement('div');
|
private statsElement: HTMLElement = document.createElement('div');
|
||||||
private killCountElement: HTMLElement = document.createElement('span');
|
private killCountElement: HTMLElement = document.createElement('span');
|
||||||
|
|
@ -50,9 +50,9 @@ export class CharacterView extends CharacterBase {
|
||||||
|
|
||||||
public isMainCharacter = false;
|
public isMainCharacter = false;
|
||||||
|
|
||||||
private leftFootExtrapolator: CircleExtrapolator;
|
private leftFootInterpolator: CircleInterpolator;
|
||||||
private rightFootExtrapolator: CircleExtrapolator;
|
private rightFootInterpolator: CircleInterpolator;
|
||||||
private headExtrapolator: CircleExtrapolator;
|
private headInterpolator: CircleInterpolator;
|
||||||
|
|
||||||
protected commandExecutors: CommandExecutors = {
|
protected commandExecutors: CommandExecutors = {
|
||||||
[RenderCommand.type]: this.draw.bind(this),
|
[RenderCommand.type]: this.draw.bind(this),
|
||||||
|
|
@ -80,9 +80,9 @@ export class CharacterView extends CharacterBase {
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.leftFootExtrapolator = new CircleExtrapolator(this.leftFoot!);
|
this.leftFootInterpolator = new CircleInterpolator(this.leftFoot!);
|
||||||
this.rightFootExtrapolator = new CircleExtrapolator(this.rightFoot!);
|
this.rightFootInterpolator = new CircleInterpolator(this.rightFoot!);
|
||||||
this.headExtrapolator = new CircleExtrapolator(this.head!);
|
this.headInterpolator = new CircleInterpolator(this.head!);
|
||||||
|
|
||||||
this.nameElement.className = 'player-tag ' + this.team;
|
this.nameElement.className = 'player-tag ' + this.team;
|
||||||
this.nameElement.innerText = this.name;
|
this.nameElement.innerText = this.name;
|
||||||
|
|
@ -140,16 +140,16 @@ export class CharacterView extends CharacterBase {
|
||||||
rateOfChange,
|
rateOfChange,
|
||||||
}: UpdatePropertyCommand) {
|
}: UpdatePropertyCommand) {
|
||||||
if (propertyKey === 'head') {
|
if (propertyKey === 'head') {
|
||||||
this.headExtrapolator.addFrame(propertyValue, rateOfChange);
|
this.headInterpolator.addFrame(propertyValue, rateOfChange);
|
||||||
}
|
}
|
||||||
if (propertyKey === 'leftFoot') {
|
if (propertyKey === 'leftFoot') {
|
||||||
this.leftFootExtrapolator.addFrame(propertyValue, rateOfChange);
|
this.leftFootInterpolator.addFrame(propertyValue, rateOfChange);
|
||||||
}
|
}
|
||||||
if (propertyKey === 'rightFoot') {
|
if (propertyKey === 'rightFoot') {
|
||||||
this.rightFootExtrapolator.addFrame(propertyValue, rateOfChange);
|
this.rightFootInterpolator.addFrame(propertyValue, rateOfChange);
|
||||||
}
|
}
|
||||||
if (propertyKey === 'strength') {
|
if (propertyKey === 'strength') {
|
||||||
this.strengthExtrapolator.addFrame(propertyValue, rateOfChange);
|
this.strengthInterpolator.addFrame(propertyValue, rateOfChange);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -194,12 +194,12 @@ export class CharacterView extends CharacterBase {
|
||||||
}
|
}
|
||||||
|
|
||||||
private step({ deltaTimeInSeconds }: StepCommand): void {
|
private step({ deltaTimeInSeconds }: StepCommand): void {
|
||||||
this.head! = this.headExtrapolator.getValue(deltaTimeInSeconds);
|
this.head! = this.headInterpolator.getValue(deltaTimeInSeconds);
|
||||||
this.leftFoot! = this.leftFootExtrapolator.getValue(deltaTimeInSeconds);
|
this.leftFoot! = this.leftFootInterpolator.getValue(deltaTimeInSeconds);
|
||||||
this.rightFoot! = this.rightFootExtrapolator.getValue(deltaTimeInSeconds);
|
this.rightFoot! = this.rightFootInterpolator.getValue(deltaTimeInSeconds);
|
||||||
|
|
||||||
this.strength = clamp(
|
this.strength = clamp(
|
||||||
this.strengthExtrapolator.getValue(deltaTimeInSeconds),
|
this.strengthInterpolator.getValue(deltaTimeInSeconds),
|
||||||
0,
|
0,
|
||||||
settings.playerMaxStrength,
|
settings.playerMaxStrength,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
import { BeforeDestroyCommand } from '../../commands/types/before-destroy';
|
import { BeforeDestroyCommand } from '../../commands/types/before-destroy';
|
||||||
import { RenderCommand } from '../../commands/types/render';
|
import { RenderCommand } from '../../commands/types/render';
|
||||||
import { StepCommand } from '../../commands/types/step';
|
import { StepCommand } from '../../commands/types/step';
|
||||||
|
import { LinearInterpolator } from '../../helper/interpolators/linear-interpolator';
|
||||||
import { PlanetShape } from '../../shapes/planet-shape';
|
import { PlanetShape } from '../../shapes/planet-shape';
|
||||||
|
|
||||||
const fallingPointLifetimeMs = 2000;
|
const fallingPointLifetimeMs = 2000;
|
||||||
|
|
@ -39,7 +40,10 @@ abstract class FlareBudget {
|
||||||
export class PlanetView extends PlanetBase {
|
export class PlanetView extends PlanetBase {
|
||||||
private shape: PlanetShape;
|
private shape: PlanetShape;
|
||||||
private ownershipProgress: HTMLElement;
|
private ownershipProgress: HTMLElement;
|
||||||
private readonly rotationSpeed: number;
|
// Rotation is owned by the backend (it drives the collision polygon too) and
|
||||||
|
// streamed in; the interpolator replays the angle on the shared snapshot
|
||||||
|
// timeline, in sync with the characters standing on the surface.
|
||||||
|
private readonly rotationInterpolator = new LinearInterpolator(0);
|
||||||
|
|
||||||
private flareLight?: CircleLight;
|
private flareLight?: CircleLight;
|
||||||
private flareIntensity = 0;
|
private flareIntensity = 0;
|
||||||
|
|
@ -56,15 +60,13 @@ export class PlanetView extends PlanetBase {
|
||||||
super(id, vertices);
|
super(id, vertices);
|
||||||
this.shape = new PlanetShape(vertices, ownership);
|
this.shape = new PlanetShape(vertices, ownership);
|
||||||
this.shape.randomOffset = Random.getRandom();
|
this.shape.randomOffset = Random.getRandom();
|
||||||
this.rotationSpeed =
|
|
||||||
(0.05 + Random.getRandom() * 0.07) * (Random.getRandom() < 0.5 ? -1 : 1);
|
|
||||||
|
|
||||||
this.ownershipProgress = document.createElement('div');
|
this.ownershipProgress = document.createElement('div');
|
||||||
this.ownershipProgress.className = 'ownership';
|
this.ownershipProgress.className = 'ownership';
|
||||||
}
|
}
|
||||||
|
|
||||||
private step({ deltaTimeInSeconds }: StepCommand): void {
|
private step({ deltaTimeInSeconds }: StepCommand): void {
|
||||||
this.shape.rotation += deltaTimeInSeconds * this.rotationSpeed;
|
this.shape.rotation = this.rotationInterpolator.getValue(deltaTimeInSeconds);
|
||||||
this.shape.colorMixQ = this.ownership;
|
this.shape.colorMixQ = this.ownership;
|
||||||
|
|
||||||
if (this.flareIntensity > 0) {
|
if (this.flareIntensity > 0) {
|
||||||
|
|
@ -120,9 +122,17 @@ export class PlanetView extends PlanetBase {
|
||||||
this.releaseFlareSlot();
|
this.releaseFlareSlot();
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateProperty({ propertyValue }: UpdatePropertyCommand): void {
|
private updateProperty({
|
||||||
|
propertyKey,
|
||||||
|
propertyValue,
|
||||||
|
rateOfChange,
|
||||||
|
}: UpdatePropertyCommand): void {
|
||||||
|
if (propertyKey === 'rotation') {
|
||||||
|
this.rotationInterpolator.addFrame(propertyValue, rateOfChange);
|
||||||
|
} else {
|
||||||
this.ownership = propertyValue;
|
this.ownership = propertyValue;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private draw({ renderer, overlay, shouldChangeLayout }: RenderCommand): void {
|
private draw({ renderer, overlay, shouldChangeLayout }: RenderCommand): void {
|
||||||
if (shouldChangeLayout) {
|
if (shouldChangeLayout) {
|
||||||
|
|
@ -137,7 +147,7 @@ export class PlanetView extends PlanetBase {
|
||||||
|
|
||||||
if (this.lastGeneratedPoint !== undefined) {
|
if (this.lastGeneratedPoint !== undefined) {
|
||||||
const element = document.createElement('div');
|
const element = document.createElement('div');
|
||||||
element.className = 'falling-point ' + (this.ownership < 0.5 ? 'decla' : 'red');
|
element.className = 'falling-point ' + (this.ownership < 0.5 ? 'blue' : 'red');
|
||||||
element.innerText = '+' + this.lastGeneratedPoint;
|
element.innerText = '+' + this.lastGeneratedPoint;
|
||||||
element.style.left = `${screenPosition.x}px`;
|
element.style.left = `${screenPosition.x}px`;
|
||||||
element.style.top = `${screenPosition.y}px`;
|
element.style.top = `${screenPosition.y}px`;
|
||||||
|
|
@ -159,12 +169,12 @@ export class PlanetView extends PlanetBase {
|
||||||
}
|
}
|
||||||
|
|
||||||
private getGradient(): string {
|
private getGradient(): string {
|
||||||
const sideDecla = this.ownership < 0.5;
|
const sideBlue = this.ownership < 0.5;
|
||||||
const sidePercent = (Math.abs(this.ownership - 0.5) / 0.5) * 100;
|
const sidePercent = (Math.abs(this.ownership - 0.5) / 0.5) * 100;
|
||||||
return sideDecla
|
return sideBlue
|
||||||
? `conic-gradient(
|
? `conic-gradient(
|
||||||
var(--bright-decla) ${sidePercent}%,
|
var(--bright-blue) ${sidePercent}%,
|
||||||
var(--bright-decla) ${sidePercent}%,
|
var(--bright-blue) ${sidePercent}%,
|
||||||
rgba(0, 0, 0, 0) ${sidePercent}%,
|
rgba(0, 0, 0, 0) ${sidePercent}%,
|
||||||
rgba(0, 0, 0, 0) 100%
|
rgba(0, 0, 0, 0) 100%
|
||||||
)`
|
)`
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,12 @@ import {
|
||||||
} from 'shared';
|
} from 'shared';
|
||||||
import { RenderCommand } from '../../commands/types/render';
|
import { RenderCommand } from '../../commands/types/render';
|
||||||
import { StepCommand } from '../../commands/types/step';
|
import { StepCommand } from '../../commands/types/step';
|
||||||
import { Vec2Extrapolator } from '../../helper/extrapolators/vec2-extrapolator';
|
import { Vec2Interpolator } from '../../helper/interpolators/vec2-interpolator';
|
||||||
|
|
||||||
export class ProjectileView extends ProjectileBase {
|
export class ProjectileView extends ProjectileBase {
|
||||||
private light: CircleLight;
|
private light: CircleLight;
|
||||||
|
|
||||||
private centerExtrapolator: Vec2Extrapolator;
|
private centerInterpolator: Vec2Interpolator;
|
||||||
|
|
||||||
protected commandExecutors: CommandExecutors = {
|
protected commandExecutors: CommandExecutors = {
|
||||||
[RenderCommand.type]: this.draw.bind(this),
|
[RenderCommand.type]: this.draw.bind(this),
|
||||||
|
|
@ -36,17 +36,17 @@ export class ProjectileView extends ProjectileBase {
|
||||||
settings.paletteDim[settings.colorIndices[team]],
|
settings.paletteDim[settings.colorIndices[team]],
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
this.centerExtrapolator = new Vec2Extrapolator(center);
|
this.centerInterpolator = new Vec2Interpolator(center);
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateProperty({ propertyValue, rateOfChange }: UpdatePropertyCommand): void {
|
private updateProperty({ propertyValue, rateOfChange }: UpdatePropertyCommand): void {
|
||||||
this.centerExtrapolator.addFrame(propertyValue, rateOfChange);
|
this.centerInterpolator.addFrame(propertyValue, rateOfChange);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleStep({ deltaTimeInSeconds }: StepCommand): void {
|
private handleStep({ deltaTimeInSeconds }: StepCommand): void {
|
||||||
this.step(deltaTimeInSeconds);
|
this.step(deltaTimeInSeconds);
|
||||||
|
|
||||||
this.center = this.centerExtrapolator.getValue(deltaTimeInSeconds);
|
this.center = this.centerInterpolator.getValue(deltaTimeInSeconds);
|
||||||
this.light.center = this.center;
|
this.light.center = this.center;
|
||||||
this.light.intensity = Math.min(
|
this.light.intensity = Math.min(
|
||||||
0.1,
|
0.1,
|
||||||
|
|
|
||||||
|
|
@ -54,19 +54,24 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
|
||||||
float l = planetLengths[j];
|
float l = planetLengths[j];
|
||||||
float randomOffset = planetRandoms[j];
|
float randomOffset = planetRandoms[j];
|
||||||
float rotation = planetRotations[j];
|
float rotation = planetRotations[j];
|
||||||
vec2 targetCenterDelta = target - center;
|
|
||||||
float targetDistance = length(targetCenterDelta);
|
|
||||||
vec2 targetTangent = targetCenterDelta / clamp(targetDistance, 0.01, 1000.0);
|
|
||||||
|
|
||||||
float cr = cos(rotation);
|
float cr = cos(rotation);
|
||||||
float sr = sin(rotation);
|
float sr = sin(rotation);
|
||||||
vec2 rotatedTangent = vec2(
|
|
||||||
cr * targetTangent.x - sr * targetTangent.y,
|
// Spin the whole planet: evaluate the SDF in the planet's own
|
||||||
sr * targetTangent.x + cr * targetTangent.y
|
// rotating frame so the polygon outline turns together with its
|
||||||
|
// terrain, instead of the terrain sliding over a fixed outline.
|
||||||
|
vec2 targetCenterDelta = target - center;
|
||||||
|
float targetDistance = length(targetCenterDelta);
|
||||||
|
vec2 localTarget = center + vec2(
|
||||||
|
cr * targetCenterDelta.x - sr * targetCenterDelta.y,
|
||||||
|
sr * targetCenterDelta.x + cr * targetCenterDelta.y
|
||||||
);
|
);
|
||||||
vec2 noisyTarget = target - (
|
vec2 targetTangent = (localTarget - center) / clamp(targetDistance, 0.01, 1000.0);
|
||||||
|
|
||||||
|
vec2 noisyTarget = localTarget - (
|
||||||
targetTangent * planetTerrain(vec2(
|
targetTangent * planetTerrain(vec2(
|
||||||
l * abs(atan(rotatedTangent.y, rotatedTangent.x)),
|
l * abs(atan(targetTangent.y, targetTangent.x)),
|
||||||
randomOffset
|
randomOffset
|
||||||
)) / 12.0
|
)) / 12.0
|
||||||
);
|
);
|
||||||
|
|
@ -97,7 +102,7 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
|
||||||
|
|
||||||
if (dist < minDistance) {
|
if (dist < minDistance) {
|
||||||
minDistance = dist;
|
minDistance = dist;
|
||||||
color = mix(${colorToString(settings.declaPlanetColor)}, ${colorToString(
|
color = mix(${colorToString(settings.bluePlanetColor)}, ${colorToString(
|
||||||
settings.redPlanetColor,
|
settings.redPlanetColor,
|
||||||
)}, planetColorMixQ[j]);
|
)}, planetColorMixQ[j]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,16 @@ import { Command } from '../command';
|
||||||
|
|
||||||
@serializable
|
@serializable
|
||||||
export class PropertyUpdatesForObjects extends Command {
|
export class PropertyUpdatesForObjects extends Command {
|
||||||
constructor(public readonly updates: Array<PropertyUpdatesForObject>) {
|
constructor(
|
||||||
|
public readonly updates: Array<PropertyUpdatesForObject>,
|
||||||
|
// Seconds on the server's monotonic clock at the moment this state was
|
||||||
|
// captured. Only differences between timestamps are meaningful.
|
||||||
|
public readonly timestamp: number,
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
public toArray(): Array<any> {
|
public toArray(): Array<any> {
|
||||||
return [this.updates];
|
return [this.updates, this.timestamp];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@ export const settings = {
|
||||||
worldRadius: 4000,
|
worldRadius: 4000,
|
||||||
objectsOnCircleLength: 0.002,
|
objectsOnCircleLength: 0.002,
|
||||||
updateMessageInterval: 1 / 25,
|
updateMessageInterval: 1 / 25,
|
||||||
|
// How far behind the estimated server time remote state is rendered: ~2.5
|
||||||
|
// update intervals, so a late packet rarely leaves the client without a
|
||||||
|
// newer snapshot to interpolate towards.
|
||||||
|
interpolationDelaySeconds: 0.1,
|
||||||
planetEdgeCount: 7,
|
planetEdgeCount: 7,
|
||||||
playerKillPoint: 25,
|
playerKillPoint: 25,
|
||||||
takeControlTimeInSeconds: 2.5,
|
takeControlTimeInSeconds: 2.5,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue