diff --git a/backend/src/commands/announce.ts b/backend/src/commands/announce.ts new file mode 100644 index 0000000..2e8aadf --- /dev/null +++ b/backend/src/commands/announce.ts @@ -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(); + } +} diff --git a/backend/src/create-world.ts b/backend/src/create-world.ts index b3941a0..a1039fc 100644 --- a/backend/src/create-world.ts +++ b/backend/src/create-world.ts @@ -1,9 +1,8 @@ import { vec2 } from 'gl-matrix'; -import { Random, PlanetBase, hsl, settings } from 'shared'; +import { Random, PlanetBase, hsl, settings, evaluateSdf } from 'shared'; import { LampPhysical } from './objects/lamp-physical'; import { PlanetPhysical } from './objects/planet-physical'; import { PhysicalContainer } from './physics/containers/physical-container'; -import { evaluateSdf } from './physics/functions/evaluate-sdf'; import { Physical } from './physics/physicals/physical'; export const createWorld = (objectContainer: PhysicalContainer) => { @@ -45,13 +44,16 @@ export const createWorld = (objectContainer: PhysicalContainer) => { ) { const planet = objects.length === 0 - ? new PlanetPhysical( + ? // The first, central giant is the keystone "Heart": a named, + // always-contested focal objective the whole match orbits. + new PlanetPhysical( PlanetBase.createPlanetVertices( position, Random.getRandomInRange(1600, 2400), Random.getRandomInRange(1600, 2400), Random.getRandomInRange(80, 300), ), + true, ) : new PlanetPhysical( PlanetBase.createPlanetVertices( diff --git a/backend/src/game-server.ts b/backend/src/game-server.ts index dd8f0d1..e321737 100644 --- a/backend/src/game-server.ts +++ b/backend/src/game-server.ts @@ -21,6 +21,7 @@ import { Options } from './options'; import { PlayerContainer } from './players/player-container'; import { StepCommand } from './commands/step'; import { GeneratePointsCommand } from './commands/generate-points'; +import { AnnounceCommand } from './commands/announce'; const gameStateSubscribedRoom = 'gameStateSubscribedRoom'; @@ -63,6 +64,8 @@ export class GameServer extends CommandReceiver { protected commandExecutors: CommandExecutors = { [GeneratePointsCommand.type]: this.addPoints.bind(this), + [AnnounceCommand.type]: ({ text }: AnnounceCommand) => + this.players.queueCommandForEachClient(new ServerAnnouncement(text)), }; constructor( @@ -164,6 +167,7 @@ export class GameServer extends CommandReceiver { } private timeSinceLastPointUpdate = 0; + private physicsAccumulator = 0; private handlePhysics() { const delta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds({ setAsBase: true }); @@ -183,14 +187,28 @@ export class GameServer extends CommandReceiver { ); } - let scaledDelta = delta; - if (this.isInEndGame) { - this.timeScaling *= Math.pow(settings.endGameDeltaScaling, delta); - scaledDelta /= this.timeScaling; + const fixedDelta = settings.targetPhysicsDeltaTimeInSeconds; + const maxSubstepsPerFrame = 5; + + this.physicsAccumulator += delta; + let substeps = Math.floor(this.physicsAccumulator / fixedDelta); + if (substeps > maxSubstepsPerFrame) { + substeps = maxSubstepsPerFrame; + this.physicsAccumulator = 0; + } else { + this.physicsAccumulator -= substeps * fixedDelta; + } + + for (let i = 0; i < substeps; i++) { + let scaledDelta = fixedDelta; + if (this.isInEndGame) { + this.timeScaling *= Math.pow(settings.endGameDeltaScaling, fixedDelta); + scaledDelta /= this.timeScaling; + } + this.objects.handleCommand(new StepCommand(scaledDelta, this)); + this.players.step(scaledDelta); } - this.objects.handleCommand(new StepCommand(scaledDelta, this)); - this.players.step(scaledDelta); this.players.stepCommunication(delta); this.objects.resetRemoteCalls(); @@ -198,7 +216,7 @@ export class GameServer extends CommandReceiver { setTimeout( this.handlePhysics.bind(this), - Math.max(0, settings.targetPhysicsDeltaTimeInSeconds - physicsDelta) * 1000, + Math.max(0, fixedDelta - physicsDelta) * 1000, ); } diff --git a/backend/src/helper/interpolate-angles.ts b/backend/src/helper/interpolate-angles.ts deleted file mode 100644 index 1fb46d6..0000000 --- a/backend/src/helper/interpolate-angles.ts +++ /dev/null @@ -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; -}; diff --git a/backend/src/objects/character-physical.ts b/backend/src/objects/character-physical.ts index 8425f93..63e0022 100644 --- a/backend/src/objects/character-physical.ts +++ b/backend/src/objects/character-physical.ts @@ -499,8 +499,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical // around the planet centre by the same per-tick angle the renderer and the // collision SDF use, so the player is carried around and turned with the // surface instead of it sliding frictionlessly underneath. The positional - // change becomes velocity in setPropertyUpdates, so the motion syncs and the - // client extrapolates it smoothly. + // change becomes velocity in setPropertyUpdates, keeping the streamed + // rate-of-change consistent with the streamed positions. private carryWithRotatingPlanet(deltaTimeInSeconds: number) { const planet = this.currentPlanet; if (!planet) { diff --git a/backend/src/objects/planet-physical.ts b/backend/src/objects/planet-physical.ts index 9e7b094..95a5e1b 100644 --- a/backend/src/objects/planet-physical.ts +++ b/backend/src/objects/planet-physical.ts @@ -5,6 +5,7 @@ import { clamp01, id, mix, + Random, serializesTo, settings, PlanetBase, @@ -28,6 +29,16 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { public readonly sizePointMultiplier: number; + // Planets slowly spin. The angle is authoritative here and streamed to the + // client (see getPropertyUpdates), so the rendered outline and this collision + // polygon turn as one rigid body. cos/sin are memoised per angle because + // distance() is called many times per tick by the raymarcher and SDF sampling. + private rotation = 0; + private readonly rotationSpeed: number; + private cachedRotation = Number.NaN; + private cosRotation = 1; + private sinRotation = 0; + private _boundingBox?: ImmutableBoundingBox; private readonly lamps: Array = []; @@ -47,23 +58,30 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { const sizeClass = clamp01( (this.radius - settings.planetMinReferenceRadius) / - (settings.planetMaxReferenceRadius - settings.planetMinReferenceRadius), + (settings.planetMaxReferenceRadius - settings.planetMinReferenceRadius), ); this.sizePointMultiplier = mix(1, settings.planetSizePointMultiplierMax, sizeClass); + + this.rotationSpeed = + (0.05 + Random.getRandom() * 0.07) * (Random.getRandom() < 0.5 ? -1 : 1); } public distance(target: vec2): number { + // Evaluate the SDF in the planet's own rotating frame so this collision + // outline turns in lockstep with the rendered planet (see planet-shape.ts). + const local = this.toLocalFrame(target); + const startEnd = this.vertices[0]; let vb = startEnd; - let d = vec2.squaredDistance(target, vb); + let d = vec2.dist(local, vb); let sign = 1; for (let i = 1; i <= this.vertices.length; i++) { const va = vb; vb = i === this.vertices.length ? startEnd : this.vertices[i]; - const targetFromDelta = vec2.subtract(vec2.create(), target, va); + const targetFromDelta = vec2.subtract(vec2.create(), local, va); const toFromDelta = vec2.subtract(vec2.create(), vb, va); const h = clamp01( vec2.dot(targetFromDelta, toFromDelta) / vec2.squaredLength(toFromDelta), @@ -75,8 +93,8 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { ); if ( - (target.y >= va.y && target.y < vb.y && ds.y > 0) || - (target.y < va.y && target.y >= vb.y && ds.y <= 0) + (local.y >= va.y && local.y < vb.y && ds.y > 0) || + (local.y < va.y && local.y >= vb.y && ds.y <= 0) ) { sign *= -1; } @@ -87,11 +105,35 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { return sign * d; } + // Rotate a world point by -rotation about the centre, matching the shader's + // `localTarget = center + R(rotation) * (target - center)` transform exactly. + private toLocalFrame(target: vec2): vec2 { + if (this.rotation !== this.cachedRotation) { + this.cachedRotation = this.rotation; + this.cosRotation = Math.cos(this.rotation); + this.sinRotation = Math.sin(this.rotation); + } + + const dx = target.x - this.center.x; + const dy = target.y - this.center.y; + + return vec2.fromValues( + this.center.x + this.cosRotation * dx - this.sinRotation * dy, + this.center.y + this.sinRotation * dx + this.cosRotation * dy, + ); + } + + // Signed angular velocity in rad/s, exposed so a character standing on the + // planet can ride its spin (see CharacterPhysical.carryWithRotatingPlanet). + public get angularVelocity(): number { + return this.rotationSpeed; + } + public get team(): CharacterTeam { return Math.abs(this.ownership - 0.5) < 0.1 ? CharacterTeam.neutral : this.ownership < 0.5 - ? CharacterTeam.decla + ? CharacterTeam.blue : CharacterTeam.red; } @@ -105,7 +147,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { ); game.handleCommand( new GeneratePointsCommand( - this.team === CharacterTeam.decla ? value : 0, + this.team === CharacterTeam.blue ? value : 0, this.team === CharacterTeam.red ? value : 0, ), ); @@ -113,6 +155,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { } private step({ deltaTimeInSeconds, game }: StepCommand) { + this.rotation += deltaTimeInSeconds * this.rotationSpeed; this.timeSinceLastPointGeneration += deltaTimeInSeconds; // In reverse order, so that teams can achieve a 100% control. @@ -135,7 +178,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { this.remoteCall('generatedPoints', reward); game.handleCommand( new GeneratePointsCommand( - currentTeam === CharacterTeam.decla ? reward : 0, + currentTeam === CharacterTeam.blue ? reward : 0, currentTeam === CharacterTeam.red ? reward : 0, ), ); @@ -153,11 +196,14 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { public getPropertyUpdates(): PropertyUpdatesForObject { return new PropertyUpdatesForObject(this.id, [ new UpdatePropertyCommand('ownership', this.ownership, 0), + // Stream rotation with rotationSpeed as the rate-of-change so the client + // can keep the angle moving when snapshots run late (see planet-view.ts). + new UpdatePropertyCommand('rotation', this.rotation, this.rotationSpeed), ]); } public takeControl(team: CharacterTeam, deltaTime: number) { - if (team === CharacterTeam.decla) { + if (team === CharacterTeam.blue) { this.ownership -= (0.5 / settings.takeControlTimeInSeconds) * deltaTime; } else if (team === CharacterTeam.red) { this.ownership += (0.5 / settings.takeControlTimeInSeconds) * deltaTime; @@ -180,22 +226,20 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical { public get boundingBox(): ImmutableBoundingBox { if (!this._boundingBox) { - const { xMin, xMax, yMin, yMax } = this.vertices.reduce( - (extremities, vertex) => ({ - xMin: Math.min(extremities.xMin, vertex.x), - xMax: Math.max(extremities.xMax, vertex.x), - yMin: Math.min(extremities.yMin, vertex.y), - yMax: Math.max(extremities.yMax, vertex.y), - }), - { - xMin: Infinity, - xMax: -Infinity, - yMin: Infinity, - yMax: -Infinity, - }, + // The polygon spins about its centre (see distance), so this static box + // has to cover every orientation, not just the spawn-time one: take the + // circumscribed circle around the rotation centre. + const maxVertexDistance = this.vertices.reduce( + (max, vertex) => Math.max(max, vec2.distance(this.center, vertex)), + 0, ); - this._boundingBox = new ImmutableBoundingBox(xMin, xMax, yMin, yMax); + this._boundingBox = new ImmutableBoundingBox( + this.center.x - maxVertexDistance, + this.center.x + maxVertexDistance, + this.center.y - maxVertexDistance, + this.center.y + maxVertexDistance, + ); } return this._boundingBox; diff --git a/backend/src/physics/functions/evaluate-sdf.ts b/backend/src/physics/functions/evaluate-sdf.ts deleted file mode 100644 index 16bc96c..0000000 --- a/backend/src/physics/functions/evaluate-sdf.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { vec2 } from 'gl-matrix'; -import { PhysicalBase } from '../physicals/physical-base'; - -export const evaluateSdf = (target: vec2, objects: Array) => - objects - .filter((i) => i.canCollide) - .reduce((min, i) => (min = Math.min(min, i.distance(target))), 1000000); diff --git a/backend/src/physics/functions/move-circle.ts b/backend/src/physics/functions/move-circle.ts deleted file mode 100644 index 7c82fdc..0000000 --- a/backend/src/physics/functions/move-circle.ts +++ /dev/null @@ -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, - 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, - }; -}; diff --git a/backend/src/players/player.ts b/backend/src/players/player.ts index 1478029..f5709bb 100644 --- a/backend/src/players/player.ts +++ b/backend/src/players/player.ts @@ -1,3 +1,4 @@ +import { performance } from 'perf_hooks'; import { vec2 } from 'gl-matrix'; import { CommandExecutors, @@ -126,6 +127,7 @@ export class Player extends PlayerBase { this.objectsPreviouslyInViewArea .map((o) => o.getPropertyUpdates()) .filter((u) => u) as Array, + performance.now() / 1000, ), ); } diff --git a/frontend/src/scripts/game.ts b/frontend/src/scripts/game.ts index bce7784..5afb42b 100644 --- a/frontend/src/scripts/game.ts +++ b/frontend/src/scripts/game.ts @@ -34,6 +34,7 @@ import { CharacterShape } from './shapes/character-shape'; import { PlanetShape } from './shapes/planet-shape'; import { RenderCommand } from './commands/types/render'; import { StepCommand } from './commands/types/step'; +import { serverTimeline } from './helper/server-timeline'; import { Tutorial } from './tutorial'; import { Scoreboard } from './scoreboard'; @@ -74,6 +75,7 @@ export class Game extends CommandReceiver { this.isBetweenGames = true; this.socket?.close(); + serverTimeline.reset(); this.gameObjects = new GameObjectContainer(this); this.overlay.innerHTML = ''; this.arrows = {}; @@ -274,6 +276,11 @@ export class Game extends CommandReceiver { this.resolveStarted(); 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; if (++this.framesSinceLastLayoutUpdate > 1) { shouldChangeLayout = true; diff --git a/frontend/src/scripts/helper/extrapolators/linear-extrapolator.ts b/frontend/src/scripts/helper/extrapolators/linear-extrapolator.ts deleted file mode 100644 index 9154fc1..0000000 --- a/frontend/src/scripts/helper/extrapolators/linear-extrapolator.ts +++ /dev/null @@ -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; - } -} diff --git a/frontend/src/scripts/helper/extrapolators/circle-extrapolator.ts b/frontend/src/scripts/helper/interpolators/circle-interpolator.ts similarity index 53% rename from frontend/src/scripts/helper/extrapolators/circle-extrapolator.ts rename to frontend/src/scripts/helper/interpolators/circle-interpolator.ts index e5b4d45..62816e5 100644 --- a/frontend/src/scripts/helper/extrapolators/circle-extrapolator.ts +++ b/frontend/src/scripts/helper/interpolators/circle-interpolator.ts @@ -1,14 +1,14 @@ import { Circle } from 'shared'; -import { LinearExtrapolator } from './linear-extrapolator'; -import { Vec2Extrapolator } from './vec2-extrapolator'; +import { LinearInterpolator } from './linear-interpolator'; +import { Vec2Interpolator } from './vec2-interpolator'; -export class CircleExtrapolator { - private center: Vec2Extrapolator; - private radius: LinearExtrapolator; +export class CircleInterpolator { + private center: Vec2Interpolator; + private radius: LinearInterpolator; constructor(currentValue: Circle) { - this.center = new Vec2Extrapolator(currentValue.center); - this.radius = new LinearExtrapolator(currentValue.radius); + this.center = new Vec2Interpolator(currentValue.center); + this.radius = new LinearInterpolator(currentValue.radius); } public addFrame(value: Circle, rateOfChange: Circle) { diff --git a/frontend/src/scripts/helper/interpolators/linear-interpolator.ts b/frontend/src/scripts/helper/interpolators/linear-interpolator.ts new file mode 100644 index 0000000..bcc23b5 --- /dev/null +++ b/frontend/src/scripts/helper/interpolators/linear-interpolator.ts @@ -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 = []; + 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) + ); + } +} diff --git a/frontend/src/scripts/helper/extrapolators/vec2-extrapolator.ts b/frontend/src/scripts/helper/interpolators/vec2-interpolator.ts similarity index 57% rename from frontend/src/scripts/helper/extrapolators/vec2-extrapolator.ts rename to frontend/src/scripts/helper/interpolators/vec2-interpolator.ts index 72ae904..ae6fb52 100644 --- a/frontend/src/scripts/helper/extrapolators/vec2-extrapolator.ts +++ b/frontend/src/scripts/helper/interpolators/vec2-interpolator.ts @@ -1,13 +1,13 @@ import { vec2 } from 'gl-matrix'; -import { LinearExtrapolator } from './linear-extrapolator'; +import { LinearInterpolator } from './linear-interpolator'; -export class Vec2Extrapolator { - private x: LinearExtrapolator; - private y: LinearExtrapolator; +export class Vec2Interpolator { + private x: LinearInterpolator; + private y: LinearInterpolator; constructor(currentValue: vec2) { - this.x = new LinearExtrapolator(currentValue.x); - this.y = new LinearExtrapolator(currentValue.y); + this.x = new LinearInterpolator(currentValue.x); + this.y = new LinearInterpolator(currentValue.y); } public addFrame(value: vec2, rateOfChange: vec2) { diff --git a/frontend/src/scripts/helper/server-timeline.ts b/frontend/src/scripts/helper/server-timeline.ts new file mode 100644 index 0000000..28aab75 --- /dev/null +++ b/frontend/src/scripts/helper/server-timeline.ts @@ -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(); diff --git a/frontend/src/scripts/objects/game-object-container.ts b/frontend/src/scripts/objects/game-object-container.ts index eb6b3dc..5abd5ef 100644 --- a/frontend/src/scripts/objects/game-object-container.ts +++ b/frontend/src/scripts/objects/game-object-container.ts @@ -13,6 +13,7 @@ import { import { BeforeDestroyCommand } from '../commands/types/before-destroy'; import { StepCommand } from '../commands/types/step'; import { Game } from '../game'; +import { serverTimeline } from '../helper/server-timeline'; import { Camera } from './types/camera'; import { CharacterView } from './types/character-view'; import { PlanetView } from './types/planet-view'; @@ -36,7 +37,7 @@ export class GameObjectContainer extends CommandReceiver { this.defaultCommandExecutor(c); 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), ), - [PropertyUpdatesForObjects.type]: (c: PropertyUpdatesForObjects) => + [PropertyUpdatesForObjects.type]: (c: PropertyUpdatesForObjects) => { + serverTimeline.onSnapshot(c.timestamp); c.updates.forEach((u) => u.updates.forEach((au) => this.objects.get(u.id)?.handleCommand(au)), - ), + ); + }, [DeleteObjectsCommand.type]: (c: DeleteObjectsCommand) => c.ids.forEach((id: Id) => this.deleteObject(id)), diff --git a/frontend/src/scripts/objects/types/camera.ts b/frontend/src/scripts/objects/types/camera.ts index 2d07048..f1cf670 100644 --- a/frontend/src/scripts/objects/types/camera.ts +++ b/frontend/src/scripts/objects/types/camera.ts @@ -13,10 +13,28 @@ export class Camera extends CommandReceiver { public center: vec2 = vec2.create(); 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) { 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 = { [RenderCommand.type]: this.draw.bind(this), }; diff --git a/frontend/src/scripts/objects/types/character-view.ts b/frontend/src/scripts/objects/types/character-view.ts index 034d97a..65424d9 100644 --- a/frontend/src/scripts/objects/types/character-view.ts +++ b/frontend/src/scripts/objects/types/character-view.ts @@ -16,8 +16,8 @@ import { import { BeforeDestroyCommand } from '../../commands/types/before-destroy'; import { RenderCommand } from '../../commands/types/render'; import { StepCommand } from '../../commands/types/step'; -import { CircleExtrapolator } from '../../helper/extrapolators/circle-extrapolator'; -import { LinearExtrapolator } from '../../helper/extrapolators/linear-extrapolator'; +import { CircleInterpolator } from '../../helper/interpolators/circle-interpolator'; +import { LinearInterpolator } from '../../helper/interpolators/linear-interpolator'; import { Pointer } from '../../helper/pointer'; import { CharacterShape } from '../../shapes/character-shape'; import { SoundHandler, Sounds } from '../../sound-handler'; @@ -40,7 +40,7 @@ export class CharacterView extends CharacterBase { private muzzleFlashIntensity = 0; private hitFlashIntensity = 0; private strength = settings.playerMaxStrength; - private strengthExtrapolator = new LinearExtrapolator(settings.playerMaxStrength); + private strengthInterpolator = new LinearInterpolator(settings.playerMaxStrength); private nameElement: HTMLElement = document.createElement('div'); private statsElement: HTMLElement = document.createElement('div'); private killCountElement: HTMLElement = document.createElement('span'); @@ -50,9 +50,9 @@ export class CharacterView extends CharacterBase { public isMainCharacter = false; - private leftFootExtrapolator: CircleExtrapolator; - private rightFootExtrapolator: CircleExtrapolator; - private headExtrapolator: CircleExtrapolator; + private leftFootInterpolator: CircleInterpolator; + private rightFootInterpolator: CircleInterpolator; + private headInterpolator: CircleInterpolator; protected commandExecutors: CommandExecutors = { [RenderCommand.type]: this.draw.bind(this), @@ -80,9 +80,9 @@ export class CharacterView extends CharacterBase { 0, ); - this.leftFootExtrapolator = new CircleExtrapolator(this.leftFoot!); - this.rightFootExtrapolator = new CircleExtrapolator(this.rightFoot!); - this.headExtrapolator = new CircleExtrapolator(this.head!); + this.leftFootInterpolator = new CircleInterpolator(this.leftFoot!); + this.rightFootInterpolator = new CircleInterpolator(this.rightFoot!); + this.headInterpolator = new CircleInterpolator(this.head!); this.nameElement.className = 'player-tag ' + this.team; this.nameElement.innerText = this.name; @@ -140,16 +140,16 @@ export class CharacterView extends CharacterBase { rateOfChange, }: UpdatePropertyCommand) { if (propertyKey === 'head') { - this.headExtrapolator.addFrame(propertyValue, rateOfChange); + this.headInterpolator.addFrame(propertyValue, rateOfChange); } if (propertyKey === 'leftFoot') { - this.leftFootExtrapolator.addFrame(propertyValue, rateOfChange); + this.leftFootInterpolator.addFrame(propertyValue, rateOfChange); } if (propertyKey === 'rightFoot') { - this.rightFootExtrapolator.addFrame(propertyValue, rateOfChange); + this.rightFootInterpolator.addFrame(propertyValue, rateOfChange); } 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 { - this.head! = this.headExtrapolator.getValue(deltaTimeInSeconds); - this.leftFoot! = this.leftFootExtrapolator.getValue(deltaTimeInSeconds); - this.rightFoot! = this.rightFootExtrapolator.getValue(deltaTimeInSeconds); + this.head! = this.headInterpolator.getValue(deltaTimeInSeconds); + this.leftFoot! = this.leftFootInterpolator.getValue(deltaTimeInSeconds); + this.rightFoot! = this.rightFootInterpolator.getValue(deltaTimeInSeconds); this.strength = clamp( - this.strengthExtrapolator.getValue(deltaTimeInSeconds), + this.strengthInterpolator.getValue(deltaTimeInSeconds), 0, settings.playerMaxStrength, ); @@ -224,7 +224,7 @@ export class CharacterView extends CharacterBase { public onShoot(strength: number) { const q = clamp01( (strength - settings.chargeShotStrengthMin) / - (settings.chargeShotStrengthMax - settings.chargeShotStrengthMin), + (settings.chargeShotStrengthMax - settings.chargeShotStrengthMin), ); SoundHandler.play(Sounds.shoot, mix(0.55, 1, q), mix(1.15, 0.8, q)); this.muzzleFlashIntensity = mix(0.35, 1, q); diff --git a/frontend/src/scripts/objects/types/planet-view.ts b/frontend/src/scripts/objects/types/planet-view.ts index 9c38963..a926237 100644 --- a/frontend/src/scripts/objects/types/planet-view.ts +++ b/frontend/src/scripts/objects/types/planet-view.ts @@ -12,6 +12,7 @@ import { import { BeforeDestroyCommand } from '../../commands/types/before-destroy'; import { RenderCommand } from '../../commands/types/render'; import { StepCommand } from '../../commands/types/step'; +import { LinearInterpolator } from '../../helper/interpolators/linear-interpolator'; import { PlanetShape } from '../../shapes/planet-shape'; const fallingPointLifetimeMs = 2000; @@ -39,7 +40,10 @@ abstract class FlareBudget { export class PlanetView extends PlanetBase { private shape: PlanetShape; 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 flareIntensity = 0; @@ -56,15 +60,13 @@ export class PlanetView extends PlanetBase { super(id, vertices); this.shape = new PlanetShape(vertices, ownership); 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.className = 'ownership'; } private step({ deltaTimeInSeconds }: StepCommand): void { - this.shape.rotation += deltaTimeInSeconds * this.rotationSpeed; + this.shape.rotation = this.rotationInterpolator.getValue(deltaTimeInSeconds); this.shape.colorMixQ = this.ownership; if (this.flareIntensity > 0) { @@ -120,8 +122,16 @@ export class PlanetView extends PlanetBase { this.releaseFlareSlot(); } - private updateProperty({ propertyValue }: UpdatePropertyCommand): void { - this.ownership = propertyValue; + private updateProperty({ + propertyKey, + propertyValue, + rateOfChange, + }: UpdatePropertyCommand): void { + if (propertyKey === 'rotation') { + this.rotationInterpolator.addFrame(propertyValue, rateOfChange); + } else { + this.ownership = propertyValue; + } } private draw({ renderer, overlay, shouldChangeLayout }: RenderCommand): void { @@ -137,7 +147,7 @@ export class PlanetView extends PlanetBase { if (this.lastGeneratedPoint !== undefined) { 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.style.left = `${screenPosition.x}px`; element.style.top = `${screenPosition.y}px`; @@ -159,12 +169,12 @@ export class PlanetView extends PlanetBase { } 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; - return sideDecla + return sideBlue ? `conic-gradient( - var(--bright-decla) ${sidePercent}%, - var(--bright-decla) ${sidePercent}%, + var(--bright-blue) ${sidePercent}%, + var(--bright-blue) ${sidePercent}%, rgba(0, 0, 0, 0) ${sidePercent}%, rgba(0, 0, 0, 0) 100% )` diff --git a/frontend/src/scripts/objects/types/projectile-view.ts b/frontend/src/scripts/objects/types/projectile-view.ts index 7a246fe..5843e59 100644 --- a/frontend/src/scripts/objects/types/projectile-view.ts +++ b/frontend/src/scripts/objects/types/projectile-view.ts @@ -10,12 +10,12 @@ import { } from 'shared'; import { RenderCommand } from '../../commands/types/render'; 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 { private light: CircleLight; - private centerExtrapolator: Vec2Extrapolator; + private centerInterpolator: Vec2Interpolator; protected commandExecutors: CommandExecutors = { [RenderCommand.type]: this.draw.bind(this), @@ -36,17 +36,17 @@ export class ProjectileView extends ProjectileBase { settings.paletteDim[settings.colorIndices[team]], 0, ); - this.centerExtrapolator = new Vec2Extrapolator(center); + this.centerInterpolator = new Vec2Interpolator(center); } private updateProperty({ propertyValue, rateOfChange }: UpdatePropertyCommand): void { - this.centerExtrapolator.addFrame(propertyValue, rateOfChange); + this.centerInterpolator.addFrame(propertyValue, rateOfChange); } private handleStep({ deltaTimeInSeconds }: StepCommand): void { this.step(deltaTimeInSeconds); - this.center = this.centerExtrapolator.getValue(deltaTimeInSeconds); + this.center = this.centerInterpolator.getValue(deltaTimeInSeconds); this.light.center = this.center; this.light.intensity = Math.min( 0.1, diff --git a/frontend/src/scripts/shapes/planet-shape.ts b/frontend/src/scripts/shapes/planet-shape.ts index 6fa7565..7a3bee5 100644 --- a/frontend/src/scripts/shapes/planet-shape.ts +++ b/frontend/src/scripts/shapes/planet-shape.ts @@ -54,19 +54,24 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) { float l = planetLengths[j]; float randomOffset = planetRandoms[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 sr = sin(rotation); - vec2 rotatedTangent = vec2( - cr * targetTangent.x - sr * targetTangent.y, - sr * targetTangent.x + cr * targetTangent.y + + // Spin the whole planet: evaluate the SDF in the planet's own + // 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( - l * abs(atan(rotatedTangent.y, rotatedTangent.x)), + l * abs(atan(targetTangent.y, targetTangent.x)), randomOffset )) / 12.0 ); @@ -97,9 +102,9 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) { if (dist < minDistance) { minDistance = dist; - color = mix(${colorToString(settings.declaPlanetColor)}, ${colorToString( - settings.redPlanetColor, - )}, planetColorMixQ[j]); + color = mix(${colorToString(settings.bluePlanetColor)}, ${colorToString( + settings.redPlanetColor, + )}, planetColorMixQ[j]); } } diff --git a/shared/src/commands/types/property-updates-for-objects.ts b/shared/src/commands/types/property-updates-for-objects.ts index a9e4985..bef2d6b 100644 --- a/shared/src/commands/types/property-updates-for-objects.ts +++ b/shared/src/commands/types/property-updates-for-objects.ts @@ -4,11 +4,16 @@ import { Command } from '../command'; @serializable export class PropertyUpdatesForObjects extends Command { - constructor(public readonly updates: Array) { + constructor( + public readonly updates: Array, + // 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(); } public toArray(): Array { - return [this.updates]; + return [this.updates, this.timestamp]; } } diff --git a/shared/src/settings.ts b/shared/src/settings.ts index 3a1a157..66aa929 100644 --- a/shared/src/settings.ts +++ b/shared/src/settings.ts @@ -18,6 +18,10 @@ export const settings = { worldRadius: 4000, objectsOnCircleLength: 0.002, 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, playerKillPoint: 25, takeControlTimeInSeconds: 2.5,