Improve netcode and simplify #4
12 changed files with 414 additions and 35 deletions
,
commit
86e97df0dc
|
|
@ -301,6 +301,7 @@ export class GameServer implements GameEvents {
|
||||||
|
|
||||||
private handlePhysics() {
|
private handlePhysics() {
|
||||||
const frameStart = process.hrtime.bigint();
|
const frameStart = process.hrtime.bigint();
|
||||||
|
const frameStartMs = performance.now();
|
||||||
const delta = Number(frameStart - this.lastPhysicsBase) / 1e9;
|
const delta = Number(frameStart - this.lastPhysicsBase) / 1e9;
|
||||||
this.lastPhysicsBase = frameStart;
|
this.lastPhysicsBase = frameStart;
|
||||||
|
|
||||||
|
|
@ -348,8 +349,13 @@ export class GameServer implements GameEvents {
|
||||||
this.players.step(scaledDelta);
|
this.players.step(scaledDelta);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Physics has only advanced to the start of this frame less whatever is still in the
|
||||||
|
// accumulator. Timing anything off the send time instead would put up to one physics step
|
||||||
|
// of noise on every interpolated position and on the client's replay anchor.
|
||||||
|
const simulatedThroughMs = frameStartMs - this.physicsAccumulator * 1000;
|
||||||
|
|
||||||
const propertyUpdates = new Map<GameObject, PropertyUpdatesForObject | undefined>();
|
const propertyUpdates = new Map<GameObject, PropertyUpdatesForObject | undefined>();
|
||||||
this.players.stepCommunication(delta, (object) => {
|
this.players.stepCommunication(delta, simulatedThroughMs, (object) => {
|
||||||
if (!propertyUpdates.has(object)) {
|
if (!propertyUpdates.has(object)) {
|
||||||
propertyUpdates.set(object, object.getPropertyUpdates());
|
propertyUpdates.set(object, object.getPropertyUpdates());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,10 +82,11 @@ export class PlayerContainer {
|
||||||
|
|
||||||
public stepCommunication(
|
public stepCommunication(
|
||||||
deltaTimeInSeconds: number,
|
deltaTimeInSeconds: number,
|
||||||
|
simulatedThroughMs: number,
|
||||||
propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined,
|
propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined,
|
||||||
) {
|
) {
|
||||||
this._players.forEach((p) =>
|
this._players.forEach((p) =>
|
||||||
p.stepCommunications(deltaTimeInSeconds, propertyUpdatesOf),
|
p.stepCommunications(deltaTimeInSeconds, simulatedThroughMs, propertyUpdatesOf),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,7 @@ export class Player extends PlayerBase {
|
||||||
|
|
||||||
public stepCommunications(
|
public stepCommunications(
|
||||||
deltaTime: number,
|
deltaTime: number,
|
||||||
|
simulatedThroughMs: number,
|
||||||
propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined,
|
propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined,
|
||||||
) {
|
) {
|
||||||
const remoteCalls: Array<RemoteCallsForObject> = [];
|
const remoteCalls: Array<RemoteCallsForObject> = [];
|
||||||
|
|
@ -180,15 +181,20 @@ export class Player extends PlayerBase {
|
||||||
this.socket.emit(TransportEvents.Ping, this.pendingPingNonce);
|
this.socket.emit(TransportEvents.Ping, this.pendingPingNonce);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subtracted, not zeroed: zeroing would stretch every interval to the next physics frame.
|
||||||
if ((this.timeSinceLastMessage += deltaTime) > settings.updateMessageInterval) {
|
if ((this.timeSinceLastMessage += deltaTime) > settings.updateMessageInterval) {
|
||||||
this.timeSinceLastMessage = 0;
|
this.timeSinceLastMessage = Math.min(
|
||||||
|
this.timeSinceLastMessage - settings.updateMessageInterval,
|
||||||
|
settings.updateMessageInterval,
|
||||||
|
);
|
||||||
this.queueAnnouncement();
|
this.queueAnnouncement();
|
||||||
this.queueSnapshot(propertyUpdatesOf);
|
this.queueSnapshot(simulatedThroughMs, propertyUpdatesOf);
|
||||||
this.sendQueuedCommandsToClient();
|
this.sendQueuedCommandsToClient();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private queueSnapshot(
|
private queueSnapshot(
|
||||||
|
simulatedThroughMs: number,
|
||||||
propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined,
|
propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined,
|
||||||
) {
|
) {
|
||||||
const { topLeft, size } = calculateViewArea(this.center, this.aspectRatio, 1.2);
|
const { topLeft, size } = calculateViewArea(this.center, this.aspectRatio, 1.2);
|
||||||
|
|
@ -227,7 +233,7 @@ export class Player extends PlayerBase {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.queueCommandSend(
|
this.queueCommandSend(
|
||||||
new PropertyUpdatesForObjects(propertyUpdates, performance.now() / 1000),
|
new PropertyUpdatesForObjects(propertyUpdates, simulatedThroughMs / 1000),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (this.character) {
|
if (this.character) {
|
||||||
|
|
@ -236,8 +242,10 @@ export class Player extends PlayerBase {
|
||||||
this.lastInputClientTimeMs,
|
this.lastInputClientTimeMs,
|
||||||
this.character.movementSnapshot,
|
this.character.movementSnapshot,
|
||||||
this.lastLeapClientTimeMs,
|
this.lastLeapClientTimeMs,
|
||||||
|
// Aged against the instant the pose is from, not the send time: the difference is
|
||||||
|
// the un-simulated remainder, and it would land straight in the client's replay.
|
||||||
this.lastInputReceiptMs > 0
|
this.lastInputReceiptMs > 0
|
||||||
? Math.max(0, performance.now() - this.lastInputReceiptMs)
|
? Math.max(0, simulatedThroughMs - this.lastInputReceiptMs)
|
||||||
: 0,
|
: 0,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,6 @@ body {
|
||||||
|
|
||||||
.player-tag {
|
.player-tag {
|
||||||
border-radius: 1000px;
|
border-radius: 1000px;
|
||||||
transition: transform 200ms;
|
|
||||||
|
|
||||||
&.blue {
|
&.blue {
|
||||||
color: $bright-blue;
|
color: $bright-blue;
|
||||||
|
|
@ -255,7 +254,6 @@ body {
|
||||||
|
|
||||||
// Off-screen pointer to the keystone "Heart", tinted by its current owner.
|
// Off-screen pointer to the keystone "Heart", tinted by its current owner.
|
||||||
.keystone-arrow {
|
.keystone-arrow {
|
||||||
transition: transform 150ms;
|
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
|
|
||||||
@include square($large-icon);
|
@include square($large-icon);
|
||||||
|
|
|
||||||
|
|
@ -347,6 +347,9 @@ export class Game extends CommandReceiver {
|
||||||
}
|
}
|
||||||
|
|
||||||
private draw() {
|
private draw() {
|
||||||
|
// Before any style writes this frame, so the read cannot force a synchronous layout.
|
||||||
|
this.minimap.measure();
|
||||||
|
|
||||||
if (this.lastGameState) {
|
if (this.lastGameState) {
|
||||||
this.scoreboard.update(this.lastGameState, this.gameObjects.localPlayer?.team);
|
this.scoreboard.update(this.lastGameState, this.gameObjects.localPlayer?.team);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,7 @@ import {
|
||||||
CharacterMovementState,
|
CharacterMovementState,
|
||||||
PhysicsBody,
|
PhysicsBody,
|
||||||
settings,
|
settings,
|
||||||
clamp,
|
followVec2,
|
||||||
easeVec2,
|
|
||||||
stepCharacterMovement,
|
stepCharacterMovement,
|
||||||
applyLeapImpulse,
|
applyLeapImpulse,
|
||||||
tickPlanetDetachment,
|
tickPlanetDetachment,
|
||||||
|
|
@ -24,10 +23,12 @@ let frameTimeMs = 0;
|
||||||
export const setFrameTimeMs = (timeMs: number): void => {
|
export const setFrameTimeMs = (timeMs: number): void => {
|
||||||
frameTimeMs = timeMs;
|
frameTimeMs = timeMs;
|
||||||
};
|
};
|
||||||
export const clientTimeMs = (): number => Math.round(frameTimeMs);
|
export const clientTimeMs = (): number => frameTimeMs;
|
||||||
|
|
||||||
const maxReplayMs = 400;
|
const maxReplayMs = 400;
|
||||||
|
|
||||||
|
// Only a correction has to fade; the character's own motion is carried straight through, so this
|
||||||
|
// buys smoothing without costing responsiveness.
|
||||||
const smoothSeconds = 0.06;
|
const smoothSeconds = 0.06;
|
||||||
|
|
||||||
const snapDistance = 250;
|
const snapDistance = 250;
|
||||||
|
|
@ -58,6 +59,7 @@ export class LocalCharacterPredictor {
|
||||||
private renderHead = new Circle(vec2.create(), headRadius);
|
private renderHead = new Circle(vec2.create(), headRadius);
|
||||||
private renderLeftFoot = new Circle(vec2.create(), feetRadius);
|
private renderLeftFoot = new Circle(vec2.create(), feetRadius);
|
||||||
private renderRightFoot = new Circle(vec2.create(), feetRadius);
|
private renderRightFoot = new Circle(vec2.create(), feetRadius);
|
||||||
|
private previousTargets = [vec2.create(), vec2.create(), vec2.create()];
|
||||||
private hasRender = false;
|
private hasRender = false;
|
||||||
|
|
||||||
public get head(): Circle {
|
public get head(): Circle {
|
||||||
|
|
@ -152,7 +154,7 @@ export class LocalCharacterPredictor {
|
||||||
this.snapRenderTo(predicted);
|
this.snapRenderTo(predicted);
|
||||||
this.hasRender = true;
|
this.hasRender = true;
|
||||||
} else {
|
} else {
|
||||||
this.easeRenderTo(predicted, frameSeconds);
|
this.followRenderTo(predicted, frameSeconds);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -161,10 +163,10 @@ export class LocalCharacterPredictor {
|
||||||
const auth = this.authoritative!;
|
const auth = this.authoritative!;
|
||||||
const movement = this.movement!;
|
const movement = this.movement!;
|
||||||
const now = clientTimeMs();
|
const now = clientTimeMs();
|
||||||
const startMs = clamp(this.replayAnchorMs!, now - maxReplayMs, now);
|
const startMs = Math.max(this.replayAnchorMs!, now - maxReplayMs);
|
||||||
const windowMs = Math.max(0, now - startMs);
|
const windowMs = Math.max(0, now - startMs);
|
||||||
const steps = Math.floor(windowMs / stepMs);
|
const steps = Math.floor(windowMs / stepMs);
|
||||||
const remainderSeconds = (windowMs - steps * stepMs) / 1000;
|
const partialStep = (windowMs - steps * stepMs) / stepMs;
|
||||||
|
|
||||||
const state: CharacterMovementState = {
|
const state: CharacterMovementState = {
|
||||||
head: makeBody(auth.head.center, auth.head.radius, upwards),
|
head: makeBody(auth.head.center, auth.head.radius, upwards),
|
||||||
|
|
@ -191,9 +193,10 @@ export class LocalCharacterPredictor {
|
||||||
let t = startMs;
|
let t = startMs;
|
||||||
for (let i = 0; i < steps; i++) {
|
for (let i = 0; i < steps; i++) {
|
||||||
const input = this.inputHistory.directionAt(t);
|
const input = this.inputHistory.directionAt(t);
|
||||||
|
// Planets first: the server's container holds them ahead of every character.
|
||||||
|
this.world.advance(stepSeconds);
|
||||||
tickPlanetDetachment(state, stepSeconds);
|
tickPlanetDetachment(state, stepSeconds);
|
||||||
stepCharacterMovement(state, this.world, input, stepSeconds);
|
stepCharacterMovement(state, this.world, input, stepSeconds);
|
||||||
this.world.advance(stepSeconds);
|
|
||||||
|
|
||||||
for (const leapMs of this.leapHistory) {
|
for (const leapMs of this.leapHistory) {
|
||||||
if (
|
if (
|
||||||
|
|
@ -213,21 +216,32 @@ export class LocalCharacterPredictor {
|
||||||
t += stepMs;
|
t += stepMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (remainderSeconds > 0) {
|
// A shorter final step would not integrate like the server's fixed one, so take a whole
|
||||||
tickPlanetDetachment(state, remainderSeconds);
|
// step and read the pose part-way along it instead.
|
||||||
|
if (partialStep > 0) {
|
||||||
|
const from = [state.head, state.leftFoot, state.rightFoot].map((b) =>
|
||||||
|
vec2.clone(b.center),
|
||||||
|
);
|
||||||
|
this.world.advance(stepSeconds);
|
||||||
|
tickPlanetDetachment(state, stepSeconds);
|
||||||
stepCharacterMovement(
|
stepCharacterMovement(
|
||||||
state,
|
state,
|
||||||
this.world,
|
this.world,
|
||||||
this.inputHistory.directionAt(now),
|
this.inputHistory.directionAt(t),
|
||||||
remainderSeconds,
|
stepSeconds,
|
||||||
|
);
|
||||||
|
[state.head, state.leftFoot, state.rightFoot].forEach((body, i) =>
|
||||||
|
vec2.lerp(body.center, from[i], body.center, partialStep),
|
||||||
);
|
);
|
||||||
this.world.advance(remainderSeconds);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
private snapRenderTo(state: CharacterMovementState): void {
|
private snapRenderTo(state: CharacterMovementState): void {
|
||||||
|
this.previousTargets = [state.head, state.leftFoot, state.rightFoot].map((b) =>
|
||||||
|
vec2.clone(b.center),
|
||||||
|
);
|
||||||
this.renderHead = new Circle(vec2.clone(state.head.center), state.head.radius);
|
this.renderHead = new Circle(vec2.clone(state.head.center), state.head.radius);
|
||||||
this.renderLeftFoot = new Circle(
|
this.renderLeftFoot = new Circle(
|
||||||
vec2.clone(state.leftFoot.center),
|
vec2.clone(state.leftFoot.center),
|
||||||
|
|
@ -239,14 +253,26 @@ export class LocalCharacterPredictor {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private easeRenderTo(state: CharacterMovementState, frameSeconds: number): void {
|
private followRenderTo(state: CharacterMovementState, frameSeconds: number): void {
|
||||||
this.easePart(this.renderHead, state.head, frameSeconds);
|
this.followPart(this.renderHead, state.head, 0, frameSeconds);
|
||||||
this.easePart(this.renderLeftFoot, state.leftFoot, frameSeconds);
|
this.followPart(this.renderLeftFoot, state.leftFoot, 1, frameSeconds);
|
||||||
this.easePart(this.renderRightFoot, state.rightFoot, frameSeconds);
|
this.followPart(this.renderRightFoot, state.rightFoot, 2, frameSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
private easePart(render: Circle, target: PhysicsBody, frameSeconds: number): void {
|
private followPart(
|
||||||
easeVec2(render.center, target.center, frameSeconds, smoothSeconds, snapDistance);
|
render: Circle,
|
||||||
|
target: PhysicsBody,
|
||||||
|
index: number,
|
||||||
|
frameSeconds: number,
|
||||||
|
): void {
|
||||||
|
followVec2(
|
||||||
|
render.center,
|
||||||
|
target.center,
|
||||||
|
this.previousTargets[index],
|
||||||
|
frameSeconds,
|
||||||
|
smoothSeconds,
|
||||||
|
snapDistance,
|
||||||
|
);
|
||||||
render.radius = target.radius;
|
render.radius = target.radius;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export class Minimap {
|
||||||
private readonly colors: Record<CharacterTeam, string>;
|
private readonly colors: Record<CharacterTeam, string>;
|
||||||
private readonly smoothed = new Map<Id, vec2>();
|
private readonly smoothed = new Map<Id, vec2>();
|
||||||
private bufferSize = 0;
|
private bufferSize = 0;
|
||||||
|
private size = 0;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.element.className = 'minimap';
|
this.element.className = 'minimap';
|
||||||
|
|
@ -23,8 +24,14 @@ export class Minimap {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reading clientWidth after this frame's style writes would force a synchronous layout of
|
||||||
|
// the whole overlay inside the animation frame; the size only changes when the window does.
|
||||||
|
public measure() {
|
||||||
|
this.size = this.element.clientWidth;
|
||||||
|
}
|
||||||
|
|
||||||
public update(localPosition: vec2 | undefined, players: Array<MinimapPlayer>) {
|
public update(localPosition: vec2 | undefined, players: Array<MinimapPlayer>) {
|
||||||
const size = this.element.clientWidth;
|
const size = this.size;
|
||||||
if (size === 0) {
|
if (size === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -119,12 +119,13 @@ export class GameObjectContainer extends CommandReceiver {
|
||||||
}
|
}
|
||||||
|
|
||||||
public render(renderer: Renderer, overlay: HTMLElement, shouldChangeLayout: boolean) {
|
public render(renderer: Renderer, overlay: HTMLElement, shouldChangeLayout: boolean) {
|
||||||
|
// First: everything below projects world coordinates through this frame's view area.
|
||||||
|
this.camera.draw(renderer);
|
||||||
this.objects.forEach((o) => {
|
this.objects.forEach((o) => {
|
||||||
if (this.isVisible(o.id)) {
|
if (this.isVisible(o.id)) {
|
||||||
o.render(renderer, overlay, shouldChangeLayout);
|
o.render(renderer, overlay, shouldChangeLayout);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.camera.draw(renderer);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private isVisible(id: Id): boolean {
|
private isVisible(id: Id): boolean {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { vec2 } from 'gl-matrix';
|
import { vec2 } from 'gl-matrix';
|
||||||
import { Renderer } from 'sdf-2d';
|
import { Renderer } from 'sdf-2d';
|
||||||
import { calculateViewArea, easeVec2, mixRgb, settings } from 'shared';
|
import { calculateViewArea, followVec2, mixRgb, settings } from 'shared';
|
||||||
import { Game } from '../../game';
|
import { Game } from '../../game';
|
||||||
import { ScreenShake } from '../../screen-shake';
|
import { ScreenShake } from '../../screen-shake';
|
||||||
|
|
||||||
|
|
@ -9,12 +9,20 @@ const snapDistance = 1500;
|
||||||
|
|
||||||
export class Camera {
|
export class Camera {
|
||||||
public readonly center: vec2 = vec2.create();
|
public readonly center: vec2 = vec2.create();
|
||||||
|
private readonly previousTarget = vec2.create();
|
||||||
private aspectRatio?: number;
|
private aspectRatio?: number;
|
||||||
|
|
||||||
constructor(private readonly game: Game) {}
|
constructor(private readonly game: Game) {}
|
||||||
|
|
||||||
public follow(target: vec2, deltaTimeInSeconds: number) {
|
public follow(target: vec2, deltaTimeInSeconds: number) {
|
||||||
easeVec2(this.center, target, deltaTimeInSeconds, followSeconds, snapDistance);
|
followVec2(
|
||||||
|
this.center,
|
||||||
|
target,
|
||||||
|
this.previousTarget,
|
||||||
|
deltaTimeInSeconds,
|
||||||
|
followSeconds,
|
||||||
|
snapDistance,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public draw(renderer: Renderer) {
|
public draw(renderer: Renderer) {
|
||||||
|
|
|
||||||
|
|
@ -13,3 +13,22 @@ export const easeVec2 = (
|
||||||
vec2.distance(out, target) > snapDistance
|
vec2.distance(out, target) > snapDistance
|
||||||
? vec2.copy(out, target)
|
? vec2.copy(out, target)
|
||||||
: vec2.lerp(out, out, target, smoothing(deltaTimeInSeconds, seconds));
|
: vec2.lerp(out, out, target, smoothing(deltaTimeInSeconds, seconds));
|
||||||
|
|
||||||
|
// Follows a *moving* target. `easeVec2` alone lags one by an amount that depends on the frame
|
||||||
|
// duration, so an uneven frame — or a dropped one — changes the lag and the follower appears to
|
||||||
|
// speed up or stall. Carrying the target's own displacement first leaves only the residual to
|
||||||
|
// decay: a target at a steady speed is tracked exactly, whatever the frame times do, and a
|
||||||
|
// correction still fades out over `seconds`.
|
||||||
|
export const followVec2 = (
|
||||||
|
out: vec2,
|
||||||
|
target: vec2,
|
||||||
|
previousTarget: vec2,
|
||||||
|
deltaTimeInSeconds: number,
|
||||||
|
seconds: number,
|
||||||
|
snapDistance: number,
|
||||||
|
): vec2 => {
|
||||||
|
out[0] += target[0] - previousTarget[0];
|
||||||
|
out[1] += target[1] - previousTarget[1];
|
||||||
|
vec2.copy(previousTarget, target);
|
||||||
|
return easeVec2(out, target, deltaTimeInSeconds, seconds, snapDistance);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { vec2 } from 'gl-matrix';
|
||||||
import { settings } from '../settings';
|
import { settings } from '../settings';
|
||||||
import { GroundSurface, PhysicsBody } from './sdf';
|
import { GroundSurface, PhysicsBody } from './sdf';
|
||||||
import { interpolateAngles } from './interpolate-angles';
|
import { interpolateAngles } from './interpolate-angles';
|
||||||
|
import { smoothing } from '../helper/ease';
|
||||||
|
|
||||||
export const headRadius = 50;
|
export const headRadius = 50;
|
||||||
export const feetRadius = 20;
|
export const feetRadius = 20;
|
||||||
|
|
@ -69,11 +70,20 @@ export const characterCenter = (
|
||||||
return vec2.scale(center, center, 1 / 3);
|
return vec2.scale(center, center, 1 / 3);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setDirection = (state: CharacterMovementState, direction: vec2) => {
|
// Tuned so one physics step still turns the same 20% it always did, but a replay that splits
|
||||||
|
// or merges steps now lands on the same facing instead of one that depends on the step count.
|
||||||
|
const directionSmoothingSeconds =
|
||||||
|
settings.targetPhysicsDeltaTimeInSeconds / Math.log(1 / (1 - 0.2));
|
||||||
|
|
||||||
|
const setDirection = (
|
||||||
|
state: CharacterMovementState,
|
||||||
|
direction: vec2,
|
||||||
|
deltaTimeInSeconds: number,
|
||||||
|
) => {
|
||||||
state.direction = interpolateAngles(
|
state.direction = interpolateAngles(
|
||||||
state.direction,
|
state.direction,
|
||||||
Math.atan2(direction[1], direction[0]) + Math.PI / 2,
|
Math.atan2(direction[1], direction[0]) + Math.PI / 2,
|
||||||
0.2,
|
smoothing(deltaTimeInSeconds, directionSmoothingSeconds),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -255,7 +265,7 @@ export const stepCharacterMovement = (
|
||||||
vec2.zero(state.rightFoot.velocity);
|
vec2.zero(state.rightFoot.velocity);
|
||||||
|
|
||||||
const movementForce = vec2.scale(
|
const movementForce = vec2.scale(
|
||||||
inputDirection,
|
vec2.create(),
|
||||||
inputDirection,
|
inputDirection,
|
||||||
settings.maxAcceleration,
|
settings.maxAcceleration,
|
||||||
);
|
);
|
||||||
|
|
@ -272,7 +282,11 @@ export const stepCharacterMovement = (
|
||||||
applyForce(state.rightFoot, rightFootGravity, deltaTimeInSeconds);
|
applyForce(state.rightFoot, rightFootGravity, deltaTimeInSeconds);
|
||||||
|
|
||||||
const sumForce = vec2.subtract(vec2.create(), leftFootGravity, movementForce);
|
const sumForce = vec2.subtract(vec2.create(), leftFootGravity, movementForce);
|
||||||
setDirection(state, vec2.length(sumForce) === 0 ? vec2.fromValues(0, -1) : sumForce);
|
setDirection(
|
||||||
|
state,
|
||||||
|
vec2.length(sumForce) === 0 ? vec2.fromValues(0, -1) : sumForce,
|
||||||
|
deltaTimeInSeconds,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
carryWithRotatingPlanet(state, deltaTimeInSeconds);
|
carryWithRotatingPlanet(state, deltaTimeInSeconds);
|
||||||
|
|
||||||
|
|
@ -310,7 +324,7 @@ export const stepCharacterMovement = (
|
||||||
if (vec2.length(gravity) <= settings.planetDetachmentForceThreshold) {
|
if (vec2.length(gravity) <= settings.planetDetachmentForceThreshold) {
|
||||||
state.currentPlanet = undefined;
|
state.currentPlanet = undefined;
|
||||||
}
|
}
|
||||||
setDirection(state, gravity);
|
setDirection(state, gravity, deltaTimeInSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
keepPosture(state);
|
keepPosture(state);
|
||||||
|
|
|
||||||
288
test/render-smoothness.test.ts
Normal file
288
test/render-smoothness.test.ts
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
LocalCharacterPredictor,
|
||||||
|
setFrameTimeMs,
|
||||||
|
} from '../frontend/src/scripts/helper/prediction/local-character-predictor';
|
||||||
|
import { ClientCharacterWorld } from '../frontend/src/scripts/helper/prediction/client-character-world';
|
||||||
|
import {
|
||||||
|
stepCharacterMovement,
|
||||||
|
tickPlanetDetachment,
|
||||||
|
headRadius,
|
||||||
|
feetRadius,
|
||||||
|
} from '../shared/src/physics/character-movement';
|
||||||
|
import { followVec2 } from '../shared/src/helper/ease';
|
||||||
|
import { settings } from '../shared/src/settings';
|
||||||
|
|
||||||
|
const stepSeconds = settings.targetPhysicsDeltaTimeInSeconds;
|
||||||
|
const snapshotMs = settings.updateMessageInterval * 1000;
|
||||||
|
const heartbeatMs = settings.clientSendInterval * 1000;
|
||||||
|
const planetRadius = 800;
|
||||||
|
|
||||||
|
// The client's frame clock is rebased to its first frame (sdf-2d passes currentTime - startTime),
|
||||||
|
// so it shares no origin with the server's clock. Anything that only works when the two happen to
|
||||||
|
// agree is not a fix.
|
||||||
|
const clientClockOffsetMs = 987_654.321;
|
||||||
|
|
||||||
|
const vertices = Array.from({ length: settings.planetEdgeCount }, (_, i) => {
|
||||||
|
const angle = (i / settings.planetEdgeCount) * -Math.PI * 2;
|
||||||
|
return [planetRadius * Math.cos(angle), planetRadius * Math.sin(angle)];
|
||||||
|
});
|
||||||
|
|
||||||
|
const planetAt = (rotation: number) =>
|
||||||
|
({ id: 1, vertices, snapshotRotation: rotation, snapshotRotationSpeed: 0 }) as never;
|
||||||
|
|
||||||
|
const body = (center: Array<number>, radius: number) => ({
|
||||||
|
center: center.slice(),
|
||||||
|
radius,
|
||||||
|
velocity: [0, 0],
|
||||||
|
lastNormal: [0, 1],
|
||||||
|
restitution: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Conditions {
|
||||||
|
fps: number;
|
||||||
|
/** One way. The round trip is twice this. */
|
||||||
|
wireMs: number;
|
||||||
|
/** Client CPU spent between the frame timestamp and the socket emit at the end of the loop. */
|
||||||
|
emitLagMs: number;
|
||||||
|
/** Peak random variation in frame duration, as a fraction of the nominal frame. */
|
||||||
|
frameJitter: number;
|
||||||
|
/** Probability that a frame is missed entirely and the next one is twice as long. */
|
||||||
|
dropRate: number;
|
||||||
|
/** Server work that can delay a socket callback past the packet's arrival. */
|
||||||
|
serverBusyMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Result {
|
||||||
|
/** Peak-to-peak variation in apparent speed, over its mean. Zero means perfectly even motion. */
|
||||||
|
speedSpread: number;
|
||||||
|
/** Peak-to-peak wobble of the character's screen position about its own mean, in world units. */
|
||||||
|
screenWobble: number;
|
||||||
|
/** How far the rendered character trails the true server pose, in world units. */
|
||||||
|
lagUnits: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs a real server tick loop, a real wire, and a real render loop against the predictor, and
|
||||||
|
// reports how steady the character's *on-screen* travel is. On-screen, not in world space: the
|
||||||
|
// camera follows the character, so what the player sees is the residual between the two.
|
||||||
|
const measure = ({
|
||||||
|
fps,
|
||||||
|
wireMs,
|
||||||
|
emitLagMs,
|
||||||
|
frameJitter,
|
||||||
|
dropRate,
|
||||||
|
serverBusyMs,
|
||||||
|
}: Conditions): Result => {
|
||||||
|
const world = new ClientCharacterWorld();
|
||||||
|
world.sync([planetAt(0)]);
|
||||||
|
|
||||||
|
const server: any = {
|
||||||
|
head: body([0, planetRadius + 90], headRadius),
|
||||||
|
leftFoot: body([-33, planetRadius + 35], feetRadius),
|
||||||
|
rightFoot: body([33, planetRadius + 35], feetRadius),
|
||||||
|
direction: 0,
|
||||||
|
currentPlanet: world.surfaceById(1),
|
||||||
|
secondsSinceOnSurface: 0,
|
||||||
|
bodyVelocity: [0, 0],
|
||||||
|
};
|
||||||
|
|
||||||
|
const input = [1, 0];
|
||||||
|
const predictor = new LocalCharacterPredictor();
|
||||||
|
predictor.setStrength(settings.playerMaxStrength);
|
||||||
|
|
||||||
|
// Deterministic frame-time noise: a real display never delivers a perfectly even cadence.
|
||||||
|
let seed = 12345;
|
||||||
|
const random = () => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff);
|
||||||
|
|
||||||
|
const nominalFrameMs = 1000 / fps;
|
||||||
|
const toServer: Array<{ atMs: number; clientStampMs: number }> = [];
|
||||||
|
const toClient: Array<{ atMs: number; ackAgeMs: number; clientStampMs: number; pose: any }> = [];
|
||||||
|
const samples: Array<{
|
||||||
|
screen: Array<number>;
|
||||||
|
world: Array<number>;
|
||||||
|
atMs: number;
|
||||||
|
error: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// The client's own view of the character, and the camera that chases it.
|
||||||
|
const camera = [0, planetRadius + 90];
|
||||||
|
const cameraPreviousTarget = [0, planetRadius + 90];
|
||||||
|
|
||||||
|
let simMs = 0;
|
||||||
|
let nextSnapshotMs = 0;
|
||||||
|
let nextFrameAtMs = 0;
|
||||||
|
let lastHeartbeatClientMs = -Infinity;
|
||||||
|
let lastInputClientTimeMs = 0;
|
||||||
|
let lastInputReceiptMs = 0;
|
||||||
|
let hasPressed = false;
|
||||||
|
|
||||||
|
|
||||||
|
while (simMs < 8000) {
|
||||||
|
tickPlanetDetachment(server, stepSeconds);
|
||||||
|
stepCharacterMovement(server, world, input.slice() as never, stepSeconds);
|
||||||
|
simMs += stepSeconds * 1000;
|
||||||
|
|
||||||
|
while (toServer.length > 0 && toServer[0].atMs <= simMs) {
|
||||||
|
const packet = toServer.shift()!;
|
||||||
|
if (packet.clientStampMs > lastInputClientTimeMs) {
|
||||||
|
lastInputClientTimeMs = packet.clientStampMs;
|
||||||
|
// Socket callbacks run between physics frames, so this is the true arrival instant,
|
||||||
|
// not the frame boundary — plus however long the frame in progress still had to run.
|
||||||
|
lastInputReceiptMs = packet.atMs + serverBusyMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (simMs >= nextSnapshotMs) {
|
||||||
|
nextSnapshotMs += snapshotMs;
|
||||||
|
toClient.push({
|
||||||
|
atMs: simMs + wireMs,
|
||||||
|
clientStampMs: lastInputClientTimeMs,
|
||||||
|
ackAgeMs: lastInputReceiptMs > 0 ? Math.max(0, simMs - lastInputReceiptMs) : 0,
|
||||||
|
pose: {
|
||||||
|
head: server.head.center.slice(),
|
||||||
|
leftFoot: server.leftFoot.center.slice(),
|
||||||
|
rightFoot: server.rightFoot.center.slice(),
|
||||||
|
movement: {
|
||||||
|
direction: server.direction,
|
||||||
|
bodyVelocity: server.bodyVelocity.slice(),
|
||||||
|
leftFootNormal: server.leftFoot.lastNormal.slice(),
|
||||||
|
rightFootNormal: server.rightFoot.lastNormal.slice(),
|
||||||
|
groundPlanetId: 1,
|
||||||
|
secondsSinceOnSurface: server.secondsSinceOnSurface,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
while (toClient.length > 0 && toClient[0].atMs <= simMs) {
|
||||||
|
const packet = toClient.shift()!;
|
||||||
|
predictor.acknowledge(
|
||||||
|
packet.clientStampMs,
|
||||||
|
packet.pose.movement,
|
||||||
|
-Infinity,
|
||||||
|
packet.ackAgeMs,
|
||||||
|
);
|
||||||
|
predictor.setAuthoritative(
|
||||||
|
{ center: packet.pose.head, radius: headRadius } as never,
|
||||||
|
{ center: packet.pose.leftFoot, radius: feetRadius } as never,
|
||||||
|
{ center: packet.pose.rightFoot, radius: feetRadius } as never,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (nextFrameAtMs <= simMs) {
|
||||||
|
const frameClientMs = nextFrameAtMs + clientClockOffsetMs;
|
||||||
|
setFrameTimeMs(frameClientMs);
|
||||||
|
|
||||||
|
// Movement input is edge-triggered, exactly as KeyboardListener sends it: recorded once
|
||||||
|
// when the key goes down, then held by both sides.
|
||||||
|
if (!hasPressed) {
|
||||||
|
hasPressed = true;
|
||||||
|
predictor.recordInput(input as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dropped = random() < dropRate;
|
||||||
|
const jitter = 1 + (random() * 2 - 1) * frameJitter;
|
||||||
|
const frameMs = nominalFrameMs * jitter * (dropped ? 2 : 1);
|
||||||
|
|
||||||
|
if (!dropped) {
|
||||||
|
predictor.update([planetAt(0)], frameMs / 1000);
|
||||||
|
const head = predictor.head.center;
|
||||||
|
followVec2(
|
||||||
|
camera as never,
|
||||||
|
head,
|
||||||
|
cameraPreviousTarget as never,
|
||||||
|
frameMs / 1000,
|
||||||
|
0.08,
|
||||||
|
1500,
|
||||||
|
);
|
||||||
|
samples.push({
|
||||||
|
screen: [head[0] - camera[0], head[1] - camera[1]],
|
||||||
|
world: [head[0], head[1]],
|
||||||
|
atMs: frameClientMs,
|
||||||
|
error: Math.hypot(head[0] - server.head.center[0], head[1] - server.head.center[1]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stamped at the top of the frame, emitted at the bottom.
|
||||||
|
if (frameClientMs - lastHeartbeatClientMs >= heartbeatMs - 1) {
|
||||||
|
lastHeartbeatClientMs = frameClientMs;
|
||||||
|
toServer.push({
|
||||||
|
atMs: nextFrameAtMs + emitLagMs + wireMs,
|
||||||
|
clientStampMs: frameClientMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
nextFrameAtMs += frameMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tail = samples.slice(Math.floor(samples.length / 2));
|
||||||
|
|
||||||
|
// Apparent speed: world travel divided by how long the frame was on screen. A longer frame
|
||||||
|
// should show proportionally more travel; anything else reads as a lurch.
|
||||||
|
const speeds = tail
|
||||||
|
.slice(1)
|
||||||
|
.map(
|
||||||
|
(s, i) =>
|
||||||
|
Math.hypot(s.world[0] - tail[i].world[0], s.world[1] - tail[i].world[1]) /
|
||||||
|
(s.atMs - tail[i].atMs),
|
||||||
|
);
|
||||||
|
const meanSpeed = speeds.reduce((a, b) => a + b, 0) / speeds.length;
|
||||||
|
|
||||||
|
// Screen position: the camera settles to a constant lag, so anything left is visible wobble.
|
||||||
|
const centre = [0, 1].map((axis) => {
|
||||||
|
const values = tail.map((s) => s.screen[axis]);
|
||||||
|
return values.reduce((a, b) => a + b, 0) / values.length;
|
||||||
|
});
|
||||||
|
const wobble = Math.max(
|
||||||
|
...tail.map((s) => Math.hypot(s.screen[0] - centre[0], s.screen[1] - centre[1])),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ((globalThis as any).__dump) {
|
||||||
|
console.log('--- apparent speed (u/ms) ---');
|
||||||
|
speeds.slice(0, 40).forEach((v, i) => console.log(`${i} ${v.toFixed(4)}`));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
speedSpread: (Math.max(...speeds) - Math.min(...speeds)) / meanSpeed,
|
||||||
|
screenWobble: wobble,
|
||||||
|
lagUnits: tail.reduce((sum, s) => sum + s.error, 0) / tail.length,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONDITIONS: Array<[string, Conditions]> = [
|
||||||
|
['localhost, clean frames', { fps: 60, wireMs: 0.2, emitLagMs: 2, frameJitter: 0, dropRate: 0, serverBusyMs: 0 }],
|
||||||
|
['localhost, 144 Hz', { fps: 144, wireMs: 0.2, emitLagMs: 2, frameJitter: 0, dropRate: 0, serverBusyMs: 0 }],
|
||||||
|
['localhost, frame jitter', { fps: 60, wireMs: 0.2, emitLagMs: 2, frameJitter: 0.15, dropRate: 0, serverBusyMs: 0 }],
|
||||||
|
['localhost, 2% dropped', { fps: 60, wireMs: 0.2, emitLagMs: 2, frameJitter: 0.05, dropRate: 0.02, serverBusyMs: 0 }],
|
||||||
|
['localhost, variable CPU', { fps: 60, wireMs: 0.2, emitLagMs: 8, frameJitter: 0.15, dropRate: 0.02, serverBusyMs: 0 }],
|
||||||
|
['30 ms link', { fps: 60, wireMs: 15, emitLagMs: 2, frameJitter: 0.05, dropRate: 0, serverBusyMs: 0 }],
|
||||||
|
['120 ms link', { fps: 60, wireMs: 60, emitLagMs: 2, frameJitter: 0.05, dropRate: 0, serverBusyMs: 0 }],
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('local character render smoothness', () => {
|
||||||
|
it('keeps on-screen travel even under realistic clocks and frame pacing', () => {
|
||||||
|
;(globalThis as any).__dump = true
|
||||||
|
measure(CONDITIONS[0][1])
|
||||||
|
;(globalThis as any).__dump = false
|
||||||
|
const results: Array<[string, Result]> = CONDITIONS.map(([label, c]) => [label, measure(c)]);
|
||||||
|
|
||||||
|
for (const [label, r] of results) {
|
||||||
|
console.log(
|
||||||
|
`${label.padEnd(26)} speed=${(r.speedSpread * 100).toFixed(1).padStart(6)}% ` +
|
||||||
|
`wobble=${r.screenWobble.toFixed(2).padStart(6)}u ` +
|
||||||
|
`lag=${r.lagUnits.toFixed(1).padStart(5)}u `,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [label, r] of results) {
|
||||||
|
// Even travel: what the eye reads as smooth motion.
|
||||||
|
expect(r.speedSpread, label).toBeLessThan(0.05);
|
||||||
|
// No sub-pixel swimming against the camera, which follows the same character.
|
||||||
|
expect(r.screenWobble, label).toBeLessThan(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prediction exists to cancel latency; a follower that lags undoes that on every link.
|
||||||
|
const localhost = results.find(([label]) => label === 'localhost, clean frames')![1];
|
||||||
|
expect(localhost.lagUnits).toBeLessThan(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue