fix movement
This commit is contained in:
parent
1295828ac8
commit
aa02d0ab6f
10 changed files with 269 additions and 149 deletions
|
|
@ -12,6 +12,7 @@ import {
|
|||
UpdatePropertyCommand,
|
||||
CommandExecutors,
|
||||
CommandReceiver,
|
||||
CharacterMovementSnapshot,
|
||||
mix,
|
||||
clamp01,
|
||||
stepCharacterMovement,
|
||||
|
|
@ -209,10 +210,18 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
|
|||
return this.currentPlanet;
|
||||
}
|
||||
|
||||
// Persistent launch momentum, streamed to the owning client so its predictor
|
||||
// can reproduce a leap/slingshot/recoil flight instead of only snapping to it.
|
||||
public get launchMomentum(): vec2 {
|
||||
return this.bodyVelocity;
|
||||
// The continuous movement state streamed to the owning client, so its
|
||||
// predictor resumes this simulation instead of guessing at the parts of it
|
||||
// the pose does not show. See CharacterMovementSnapshot.
|
||||
public get movementSnapshot(): CharacterMovementSnapshot {
|
||||
return new CharacterMovementSnapshot(
|
||||
this.direction,
|
||||
vec2.clone(this.bodyVelocity),
|
||||
vec2.clone(this.leftFoot.lastNormal),
|
||||
vec2.clone(this.rightFoot.lastNormal),
|
||||
this.currentPlanet?.id ?? null,
|
||||
this.secondsSinceOnSurface,
|
||||
);
|
||||
}
|
||||
|
||||
public addKill(victimName: string, charge = 0) {
|
||||
|
|
|
|||
|
|
@ -283,7 +283,7 @@ export class Player extends PlayerBase {
|
|||
this.queueCommandSend(
|
||||
new InputAcknowledgement(
|
||||
this.lastInputClientTimeMs,
|
||||
this.character.launchMomentum,
|
||||
this.character.movementSnapshot,
|
||||
this.lastLeapClientTimeMs,
|
||||
this.lastInputReceiptMs > 0
|
||||
? Math.max(0, performance.now() - this.lastInputReceiptMs)
|
||||
|
|
@ -369,6 +369,10 @@ export class Player extends PlayerBase {
|
|||
const sheddable: ReadonlyArray<string> = [
|
||||
PropertyUpdatesForObjects.type,
|
||||
UpdateMinimap.type,
|
||||
// Only meaningful together with the pose it describes, and regenerated
|
||||
// in full next tick — so it is shed with it rather than advancing the
|
||||
// client's replay anchor past a snapshot it never received.
|
||||
InputAcknowledgement.type,
|
||||
];
|
||||
this.commandsToBeSent = this.commandsToBeSent.filter(
|
||||
(c) => !sheddable.includes(c.type),
|
||||
|
|
|
|||
|
|
@ -36,7 +36,10 @@ 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 { localCharacterPredictor } from './helper/prediction/local-character-predictor';
|
||||
import {
|
||||
localCharacterPredictor,
|
||||
setFrameTimeMs,
|
||||
} from './helper/prediction/local-character-predictor';
|
||||
import { Tutorial } from './tutorial';
|
||||
import { Scoreboard } from './scoreboard';
|
||||
import { Minimap } from './minimap';
|
||||
|
|
@ -226,7 +229,7 @@ export class Game extends CommandReceiver {
|
|||
[InputAcknowledgement.type]: (c: InputAcknowledgement) =>
|
||||
localCharacterPredictor.acknowledge(
|
||||
c.clientTimeMs,
|
||||
c.bodyVelocity,
|
||||
c.movement,
|
||||
c.lastLeapClientTimeMs,
|
||||
c.ackAgeMs,
|
||||
),
|
||||
|
|
@ -334,10 +337,16 @@ export class Game extends CommandReceiver {
|
|||
private framesSinceLastLayoutUpdate = 0;
|
||||
private gameLoop(
|
||||
renderer: Renderer,
|
||||
_: DOMHighResTimeStamp,
|
||||
currentTime: DOMHighResTimeStamp,
|
||||
deltaTime: DOMHighResTimeStamp,
|
||||
): boolean {
|
||||
this.resolveStarted();
|
||||
// The client's one clock, for input stamps, the outgoing send cadence and
|
||||
// the prediction replay window alike. It has to be the frame's timestamp
|
||||
// rather than performance.now(): the two differ by however long the browser
|
||||
// took to dispatch this callback, and that difference would land straight
|
||||
// in the replay window and jitter the predicted body.
|
||||
setFrameTimeMs(currentTime);
|
||||
deltaTime /= 1000;
|
||||
|
||||
// Decay the camera impact effects on raw wall-clock time, before any of the
|
||||
|
|
|
|||
|
|
@ -118,10 +118,6 @@ export class ClientCharacterWorld implements CharacterWorld {
|
|||
return id == null ? undefined : this.surfaces.get(id);
|
||||
}
|
||||
|
||||
public idOf(surface: GroundSurface | undefined): Id | undefined {
|
||||
return surface instanceof PlanetSurface ? surface.id : undefined;
|
||||
}
|
||||
|
||||
public groundsNear(): Array<GroundSurface> {
|
||||
return this.ordered;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import {
|
||||
Circle,
|
||||
CharacterMovementSnapshot,
|
||||
CharacterMovementState,
|
||||
Id,
|
||||
PhysicsBody,
|
||||
settings,
|
||||
stepCharacterMovement,
|
||||
|
|
@ -17,18 +17,22 @@ import { InputHistory } from './input-history';
|
|||
const stepMs = 1000 / 200; // match the server's 200 Hz fixed tick
|
||||
const stepSeconds = 1 / 200;
|
||||
|
||||
// Clock source for the predictor. Injectable so deterministic reconciliation
|
||||
// tests can drive prediction without real time passing; defaults to the
|
||||
// browser wall clock in production.
|
||||
let nowMs: () => number = () => performance.now();
|
||||
export const setPredictorClockForTesting = (clock: () => number): void => {
|
||||
nowMs = clock;
|
||||
// The client's clock: the timestamp of the frame currently being rendered, set
|
||||
// once per frame by the game loop (requestAnimationFrame hands it to us
|
||||
// already). Everything time-related on the client reads it from here, so input
|
||||
// stamps, the outgoing send cadence and the replay window all sit on one
|
||||
// timeline.
|
||||
//
|
||||
// Sampling performance.now() ad hoc instead — which is what this used to do —
|
||||
// reads the frame's *dispatch* time, and the few ms of scheduling jitter
|
||||
// between the frame's timestamp and the callback actually running went straight
|
||||
// into the replay window. The body then advanced by a slightly different amount
|
||||
// than the frame it was drawn in.
|
||||
let frameTimeMs = 0;
|
||||
export const setFrameTimeMs = (timeMs: number): void => {
|
||||
frameTimeMs = timeMs;
|
||||
};
|
||||
|
||||
// The same clock the predictor stamps input with. Anything that tells the server
|
||||
// "this is how far my input timeline has got" must read it from here, or the
|
||||
// acknowledgement comes back in a different time base than the replay window.
|
||||
export const predictorNowMs = (): number => nowMs();
|
||||
export const predictorNowMs = (): number => frameTimeMs;
|
||||
|
||||
// Don't replay more than this far back: if the last acknowledged input is older
|
||||
// (a stall, or a backgrounded tab catching up) fall back to a shorter window
|
||||
|
|
@ -46,11 +50,15 @@ const smoothSeconds = 0.06;
|
|||
// the predictor doesn't model. Snap to it rather than gliding across the gap.
|
||||
const snapDistance = 250;
|
||||
|
||||
const makeBody = (center: vec2, radius: number): PhysicsBody => ({
|
||||
const upwards = vec2.fromValues(0, 1);
|
||||
|
||||
// Velocity is deliberately zero: the movement simulation rebuilds it from
|
||||
// scratch every tick and zeroes it again at the end, so it is not carried state.
|
||||
const makeBody = (center: vec2, radius: number, normal: vec2): PhysicsBody => ({
|
||||
center: vec2.clone(center),
|
||||
radius,
|
||||
velocity: vec2.create(),
|
||||
lastNormal: vec2.fromValues(0, 1),
|
||||
lastNormal: vec2.fromValues(normal[0], normal[1]),
|
||||
restitution: 0,
|
||||
});
|
||||
|
||||
|
|
@ -77,12 +85,12 @@ export class LocalCharacterPredictor {
|
|||
// snapshot was actually taken. Both halves are needed.
|
||||
//
|
||||
// Anchoring on the acknowledged input time ALONE beats against the send
|
||||
// cadence: input is sent once per frame, so at snapshot time the newest input
|
||||
// the server holds is 0..1 frame old depending on where the client's frames
|
||||
// fell, and that age lands directly in the replay window. Measured on
|
||||
// localhost it walked a sawtooth — 17, 10, 6, 1, 11, 6, 2, 12 ms — so the
|
||||
// window jumped by up to a frame of travel every snapshot and the body
|
||||
// stuttered at 25 Hz. ackAgeMs cancels it exactly.
|
||||
// cadence: at snapshot time the newest input the server holds is 0..1 send
|
||||
// interval old depending on where the client's sends fell, and that age lands
|
||||
// directly in the replay window. Measured on localhost it walked a sawtooth —
|
||||
// 17, 10, 6, 1, 11, 6, 2, 12 ms — so the window jumped by up to a frame of
|
||||
// travel every snapshot and the body stuttered at 25 Hz. ackAgeMs cancels it
|
||||
// exactly.
|
||||
//
|
||||
// Anchoring on the snapshot's ARRIVAL is free of that beat, but it under-
|
||||
// advances by a one-way trip, so the local body trails its own input at any
|
||||
|
|
@ -93,14 +101,17 @@ export class LocalCharacterPredictor {
|
|||
// altogether; that is fixed at the source, by closing every outgoing batch
|
||||
// with a ClientHeartbeatCommand.
|
||||
private replayAnchorMs?: number;
|
||||
// Authoritative launch momentum at the last snapshot — seeds each replay so a
|
||||
// leap/slingshot/recoil flight is reproduced and continuously corrected.
|
||||
private authoritativeBodyVelocity = vec2.create();
|
||||
// Wall-clock times the player issued a leap, replayed (with the impulse
|
||||
// applied locally) so the launch is felt immediately, not after a round trip.
|
||||
// The rest of the server's movement state at that same instant: facing
|
||||
// direction, launch momentum, foot contact normals and the latched planet.
|
||||
// Together with the pose above it is everything stepCharacterMovement carries
|
||||
// between ticks, so a replay continues the server's simulation instead of
|
||||
// approximating it.
|
||||
private movement?: CharacterMovementSnapshot;
|
||||
// Frame times the player issued a leap, replayed (with the impulse applied
|
||||
// locally) so the launch is felt immediately, not after a round trip.
|
||||
private leapHistory: Array<number> = [];
|
||||
// clientTimeMs of the last leap the server has folded into the streamed
|
||||
// momentum. Leaps at or before this are already in authoritativeBodyVelocity;
|
||||
// momentum. Leaps at or before this are already in movement.bodyVelocity;
|
||||
// only newer ones are replayed, so a leap is never applied twice.
|
||||
private lastLeapAckMs = -Infinity;
|
||||
// Latest streamed shooting-strength, to gate predicted leaps as the server does.
|
||||
|
|
@ -112,13 +123,6 @@ export class LocalCharacterPredictor {
|
|||
// that still moved would be a pure client-side desync.
|
||||
private alive = true;
|
||||
|
||||
// Continuous state carried between replays (the snapshot carries only poses).
|
||||
// The facing direction is NOT carried — it is re-derived from the pose each
|
||||
// frame (see directionFromPose / simulate); only the latched planet and the
|
||||
// time-since-surface persist.
|
||||
private carriedPlanetId?: Id;
|
||||
private carriedSecondsSinceSurface = 1;
|
||||
|
||||
// The eased, rendered pose handed to the view.
|
||||
private renderHead = new Circle(vec2.create(), headRadius);
|
||||
private renderLeftFoot = new Circle(vec2.create(), feetRadius);
|
||||
|
|
@ -135,27 +139,27 @@ export class LocalCharacterPredictor {
|
|||
return this.renderRightFoot;
|
||||
}
|
||||
|
||||
// Stamp a movement command and record it for replay. Returns the wall-clock
|
||||
// Stamp a movement command and record it for replay. Returns the client-clock
|
||||
// time the command should carry so the server can echo it back.
|
||||
public recordInput(direction: vec2): number {
|
||||
const timeMs = Math.round(nowMs());
|
||||
const timeMs = Math.round(frameTimeMs);
|
||||
this.inputHistory.record(direction, timeMs);
|
||||
return timeMs;
|
||||
}
|
||||
|
||||
public acknowledge(
|
||||
clientTimeMs: number,
|
||||
bodyVelocity: vec2,
|
||||
movement: CharacterMovementSnapshot,
|
||||
lastLeapClientTimeMs: number,
|
||||
ackAgeMs = 0,
|
||||
): void {
|
||||
// Inputs only advance the acknowledgement forward; the launch momentum and
|
||||
// Inputs only advance the acknowledgement forward; the movement state and
|
||||
// leap boundary always adopt the latest authoritative values.
|
||||
const anchor = clientTimeMs + Math.max(0, ackAgeMs);
|
||||
if (this.replayAnchorMs === undefined || anchor > this.replayAnchorMs) {
|
||||
this.replayAnchorMs = anchor;
|
||||
}
|
||||
vec2.set(this.authoritativeBodyVelocity, bodyVelocity[0], bodyVelocity[1]);
|
||||
this.movement = movement;
|
||||
this.lastLeapAckMs = lastLeapClientTimeMs;
|
||||
}
|
||||
|
||||
|
|
@ -168,7 +172,7 @@ export class LocalCharacterPredictor {
|
|||
// it — a rejected leap (no strength/cooldown) self-corrects via the streamed
|
||||
// authoritative momentum.
|
||||
public recordLeap(): number {
|
||||
const timeMs = Math.round(nowMs());
|
||||
const timeMs = Math.round(frameTimeMs);
|
||||
this.leapHistory.push(timeMs);
|
||||
const cutoff = timeMs - 1500;
|
||||
while (this.leapHistory.length > 0 && this.leapHistory[0] <= cutoff) {
|
||||
|
|
@ -191,16 +195,18 @@ export class LocalCharacterPredictor {
|
|||
this.lastLeapAckMs = -Infinity;
|
||||
this.authoritative = undefined;
|
||||
this.replayAnchorMs = undefined;
|
||||
vec2.zero(this.authoritativeBodyVelocity);
|
||||
this.movement = undefined;
|
||||
this.currentStrength = settings.playerMaxStrength;
|
||||
this.carriedPlanetId = undefined;
|
||||
this.carriedSecondsSinceSurface = 1;
|
||||
this.hasRender = false;
|
||||
this.alive = true;
|
||||
}
|
||||
|
||||
public get canPredict(): boolean {
|
||||
return this.authoritative !== undefined && this.replayAnchorMs !== undefined;
|
||||
return (
|
||||
this.authoritative !== undefined &&
|
||||
this.movement !== undefined &&
|
||||
this.replayAnchorMs !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
// During spawn-in and death the server freezes walking and only scales the
|
||||
|
|
@ -237,22 +243,10 @@ export class LocalCharacterPredictor {
|
|||
return true;
|
||||
}
|
||||
|
||||
// The body's facing angle is encoded in the pose: each part is sprung toward
|
||||
// center + R(direction)*offset and the head's offset points +y, so
|
||||
// direction = atan2(head - center) - PI/2. Re-deriving it from the snapshot
|
||||
// each frame (rather than carrying the previous frame's evolved value onto
|
||||
// this past pose, re-evolved by a variable substep count) keeps the posture
|
||||
// seed a pure function of the snapshot — carrying it fed a frame-rate-dependent
|
||||
// loop that wobbled the rendered limbs.
|
||||
private directionFromPose(head: Circle, leftFoot: Circle, rightFoot: Circle): number {
|
||||
const cx = (head.center[0] + leftFoot.center[0] + rightFoot.center[0]) / 3;
|
||||
const cy = (head.center[1] + leftFoot.center[1] + rightFoot.center[1]) / 3;
|
||||
return Math.atan2(head.center[1] - cy, head.center[0] - cx) - Math.PI / 2;
|
||||
}
|
||||
|
||||
private simulate(): CharacterMovementState {
|
||||
const auth = this.authoritative!;
|
||||
const now = Math.round(nowMs());
|
||||
const movement = this.movement!;
|
||||
const now = Math.round(frameTimeMs);
|
||||
// Replay every input the server has not confirmed yet: from the anchor up to
|
||||
// now, clamped so a stall (or a backgrounded tab catching up) cannot grind
|
||||
// through hundreds of steps.
|
||||
|
|
@ -261,16 +255,30 @@ export class LocalCharacterPredictor {
|
|||
const steps = Math.floor(windowMs / stepMs);
|
||||
const remainderSeconds = (windowMs - steps * stepMs) / 1000;
|
||||
|
||||
// The full authoritative state: the pose from the property updates, the
|
||||
// rest from the acknowledgement. Nothing here is inferred or carried over
|
||||
// from the previous frame, so a replay is a pure function of (snapshot,
|
||||
// input history, window) and cannot drift.
|
||||
const state: CharacterMovementState = {
|
||||
head: makeBody(auth.head.center, auth.head.radius),
|
||||
leftFoot: makeBody(auth.leftFoot.center, auth.leftFoot.radius),
|
||||
rightFoot: makeBody(auth.rightFoot.center, auth.rightFoot.radius),
|
||||
direction: this.directionFromPose(auth.head, auth.leftFoot, auth.rightFoot),
|
||||
currentPlanet: this.world.surfaceById(this.carriedPlanetId),
|
||||
secondsSinceOnSurface: this.carriedSecondsSinceSurface,
|
||||
// The head's contact normal is written by the collision but never read by
|
||||
// the movement, so it does not need to travel.
|
||||
head: makeBody(auth.head.center, auth.head.radius, upwards),
|
||||
leftFoot: makeBody(
|
||||
auth.leftFoot.center,
|
||||
auth.leftFoot.radius,
|
||||
movement.leftFootNormal,
|
||||
),
|
||||
rightFoot: makeBody(
|
||||
auth.rightFoot.center,
|
||||
auth.rightFoot.radius,
|
||||
movement.rightFootNormal,
|
||||
),
|
||||
direction: movement.direction,
|
||||
currentPlanet: this.world.surfaceById(movement.groundPlanetId),
|
||||
secondsSinceOnSurface: movement.secondsSinceOnSurface,
|
||||
// Leaps before the replay window are already baked into this; leaps inside
|
||||
// the window are re-applied below, so neither is double-counted.
|
||||
bodyVelocity: vec2.clone(this.authoritativeBodyVelocity),
|
||||
bodyVelocity: vec2.fromValues(movement.bodyVelocity[0], movement.bodyVelocity[1]),
|
||||
};
|
||||
|
||||
// The planet collision frames were synced (in update(), just before this)
|
||||
|
|
@ -332,9 +340,6 @@ export class LocalCharacterPredictor {
|
|||
this.world.advance(remainderSeconds);
|
||||
}
|
||||
|
||||
this.carriedPlanetId = this.world.idOf(state.currentPlanet);
|
||||
this.carriedSecondsSinceSurface = state.secondsSinceOnSurface;
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,29 +1,72 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { Id } from '../../communication/id';
|
||||
import { serializable } from '../../serialization/serializable';
|
||||
import { Command } from '../command';
|
||||
|
||||
/**
|
||||
* Everything the character's movement simulation carries from one tick to the
|
||||
* next that is *not* in the pose.
|
||||
*
|
||||
* The pose (head/feet circles) travels as ordinary property updates because
|
||||
* every viewer needs it; this travels to the owning client only. Together they
|
||||
* are the complete state `stepCharacterMovement` needs, so the predictor can
|
||||
* resume the server's simulation rather than approximate it.
|
||||
*
|
||||
* It all has to be here. `direction` in particular is not recoverable from the
|
||||
* pose: the body is sprung *towards* the posture that direction implies and
|
||||
* lags it while walking, so deriving the angle back out of a walking pose is
|
||||
* off by tens of degrees — and every replay then spent its first ticks
|
||||
* twisting the body back, by an amount that varied with the replay length.
|
||||
*/
|
||||
@serializable
|
||||
export class CharacterMovementSnapshot {
|
||||
public constructor(
|
||||
public readonly direction: number,
|
||||
// Persistent launch momentum (leap / slingshot / recoil), so the predictor
|
||||
// reproduces a flight rather than only snapping to it.
|
||||
public readonly bodyVelocity: vec2,
|
||||
// Foot contact normals: the on-surface branch projects gravity onto these,
|
||||
// so a body standing on a slope needs the real ones.
|
||||
public readonly leftFootNormal: vec2,
|
||||
public readonly rightFootNormal: vec2,
|
||||
// The planet being stood on, or null while airborne.
|
||||
public readonly groundPlanetId: Id,
|
||||
public readonly secondsSinceOnSurface: number,
|
||||
) {}
|
||||
|
||||
public toArray(): Array<any> {
|
||||
return [
|
||||
this.direction,
|
||||
this.bodyVelocity,
|
||||
this.leftFootNormal,
|
||||
this.rightFootNormal,
|
||||
this.groundPlanetId,
|
||||
this.secondsSinceOnSurface,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Sent server -> owning client only, alongside that client's own character
|
||||
// snapshot. Carries the clientTimeMs of the most recent movement input the
|
||||
// server had received when the snapshot was taken (so the client's predictor
|
||||
// can reset to the snapshot and replay just the inputs the server hasn't seen
|
||||
// yet) and the authoritative launch momentum (so the predictor reproduces a
|
||||
// leap/slingshot/recoil flight rather than only snapping to it). See
|
||||
// snapshot. Carries the clientTimeMs of the most recent input the server had
|
||||
// received when the snapshot was taken (so the client's predictor can reset to
|
||||
// the snapshot and replay just the inputs the server hasn't seen yet) and the
|
||||
// authoritative movement state the replay resumes from. See
|
||||
// LocalCharacterPredictor.
|
||||
@serializable
|
||||
export class InputAcknowledgement extends Command {
|
||||
public constructor(
|
||||
public readonly clientTimeMs: number,
|
||||
public readonly bodyVelocity: vec2,
|
||||
public readonly movement: CharacterMovementSnapshot,
|
||||
// clientTimeMs of the most recent leap the server has received: any leap at
|
||||
// or before it is already reflected in bodyVelocity, so the predictor must
|
||||
// not replay it.
|
||||
// or before it is already reflected in movement.bodyVelocity, so the
|
||||
// predictor must not replay it.
|
||||
public readonly lastLeapClientTimeMs: number,
|
||||
// How long before this snapshot the acknowledged input arrived. The client
|
||||
// sends at its frame rate, so by the time a snapshot is taken the newest
|
||||
// input it has is already 0..1 frame old — and that age varies with where
|
||||
// the client's frames happened to fall between two snapshots. Adding it
|
||||
// back places the replay anchor at the snapshot instant itself instead of
|
||||
// at whichever input happened to land last, which is what keeps the
|
||||
// sends at a fixed cadence, so by the time a snapshot is taken the newest
|
||||
// input it has is already 0..1 send interval old — and that age varies with
|
||||
// where the client's sends happened to fall between two snapshots. Adding
|
||||
// it back places the replay anchor at the snapshot instant itself instead
|
||||
// of at whichever input happened to land last, which is what keeps the
|
||||
// predicted pose free of the send-cadence beat. See LocalCharacterPredictor.
|
||||
public readonly ackAgeMs: number = 0,
|
||||
) {
|
||||
|
|
@ -31,11 +74,6 @@ export class InputAcknowledgement extends Command {
|
|||
}
|
||||
|
||||
public toArray(): Array<any> {
|
||||
return [
|
||||
this.clientTimeMs,
|
||||
this.bodyVelocity,
|
||||
this.lastLeapClientTimeMs,
|
||||
this.ackAgeMs,
|
||||
];
|
||||
return [this.clientTimeMs, this.movement, this.lastLeapClientTimeMs, this.ackAgeMs];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
export const rotateMinus90Deg = (vec: vec2): vec2 => vec2.fromValues(vec[1], -vec[0]);
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export const wait = (ms: number): Promise<void> => {
|
||||
return new Promise<void>((resolve, _) => setTimeout(resolve, ms));
|
||||
};
|
||||
|
|
@ -7,7 +7,7 @@ import { describe, it, expect } from 'vitest';
|
|||
import { createRequire } from 'node:module';
|
||||
|
||||
import { CommandSocket } from '../frontend/src/scripts/commands/command-socket';
|
||||
import { setPredictorClockForTesting } from '../frontend/src/scripts/helper/prediction/local-character-predictor';
|
||||
import { setFrameTimeMs } from '../frontend/src/scripts/helper/prediction/local-character-predictor';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const shared = require('../shared/lib/main.js');
|
||||
|
|
@ -18,16 +18,13 @@ applyArrayPlugins();
|
|||
|
||||
// Collects what would go on the wire.
|
||||
const drive = (frameRate: number, seconds: number, onFrame?: (socket: any) => void) => {
|
||||
let clock = 0;
|
||||
setPredictorClockForTesting(() => clock);
|
||||
|
||||
const batches: Array<string> = [];
|
||||
const socket = { emit: (_event: string, payload: string) => batches.push(payload) };
|
||||
const commandSocket = new CommandSocket(socket as never);
|
||||
|
||||
const frames = Math.round(frameRate * seconds);
|
||||
for (let f = 0; f < frames; f++) {
|
||||
clock = (f * 1000) / frameRate;
|
||||
setFrameTimeMs((f * 1000) / frameRate);
|
||||
onFrame?.(commandSocket);
|
||||
commandSocket.sendQueuedCommands();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
// Reconciliation guard for the REAL client predictor. It exercises
|
||||
// LocalCharacterPredictor end-to-end with an injected clock, verifying the two
|
||||
// LocalCharacterPredictor end-to-end with a driven frame clock, verifying the
|
||||
// properties reconciliation depends on:
|
||||
// 1. a fully-acknowledged snapshot reproduces the authoritative pose exactly
|
||||
// (no spurious drift on top of server truth), and
|
||||
// 2. un-acknowledged input is replayed forward deterministically.
|
||||
// (no spurious drift on top of server truth),
|
||||
// 2. un-acknowledged input is replayed forward deterministically, and
|
||||
// 3. the replayed body advances smoothly as the window grows.
|
||||
// This is the net for prediction-touching changes (the death-while-dead fix,
|
||||
// future netcode work) — a regression shows up as drift or non-determinism.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
LocalCharacterPredictor,
|
||||
setPredictorClockForTesting,
|
||||
setFrameTimeMs,
|
||||
} from '../frontend/src/scripts/helper/prediction/local-character-predictor';
|
||||
|
||||
const HEAD_RADIUS = 50;
|
||||
|
|
@ -23,18 +24,33 @@ const poseAt = (cx: number, cy: number) => ({
|
|||
rightFoot: { center: [cx + 33, cy - 18], radius: FEET_RADIUS },
|
||||
});
|
||||
|
||||
// The authoritative movement state that rides along with every snapshot: the
|
||||
// pose is only half of what the simulation carries between ticks. Upright,
|
||||
// airborne and with no launch momentum unless a test says otherwise.
|
||||
const movementState = (direction = 0) => ({
|
||||
direction,
|
||||
bodyVelocity: [0, 0],
|
||||
leftFootNormal: [0, 1],
|
||||
rightFootNormal: [0, 1],
|
||||
groundPlanetId: null,
|
||||
secondsSinceOnSurface: 1,
|
||||
});
|
||||
|
||||
describe('local prediction reconciliation', () => {
|
||||
it('reproduces the authoritative pose exactly when all input is acknowledged', () => {
|
||||
let clock = 1000;
|
||||
setPredictorClockForTesting(() => clock);
|
||||
setFrameTimeMs(1000);
|
||||
|
||||
const predictor = new LocalCharacterPredictor();
|
||||
const auth = poseAt(500, 500);
|
||||
|
||||
const t = predictor.recordInput([1, 0]);
|
||||
predictor.acknowledge(t, [0, 0], -Infinity); // server has consumed this input
|
||||
predictor.acknowledge(t, movementState() as never, -Infinity); // server has consumed this input
|
||||
predictor.setStrength(80);
|
||||
predictor.setAuthoritative(auth.head as never, auth.leftFoot as never, auth.rightFoot as never);
|
||||
predictor.setAuthoritative(
|
||||
auth.head as never,
|
||||
auth.leftFoot as never,
|
||||
auth.rightFoot as never,
|
||||
);
|
||||
|
||||
// No clock advance → zero replay window → predicted pose == authoritative.
|
||||
const used = predictor.update([], 1 / 60);
|
||||
|
|
@ -48,18 +64,19 @@ describe('local prediction reconciliation', () => {
|
|||
|
||||
it('replays un-acknowledged input deterministically', () => {
|
||||
const drive = () => {
|
||||
let clock = 0;
|
||||
setPredictorClockForTesting(() => clock);
|
||||
|
||||
const predictor = new LocalCharacterPredictor();
|
||||
clock = 1000;
|
||||
predictor.acknowledge(900, [0, 0], -Infinity); // baseline ack (older than the input below)
|
||||
setFrameTimeMs(1000);
|
||||
predictor.acknowledge(900, movementState() as never, -Infinity); // baseline ack (older than the input below)
|
||||
predictor.setStrength(80);
|
||||
const auth = poseAt(0, 0);
|
||||
predictor.setAuthoritative(auth.head as never, auth.leftFoot as never, auth.rightFoot as never);
|
||||
predictor.setAuthoritative(
|
||||
auth.head as never,
|
||||
auth.leftFoot as never,
|
||||
auth.rightFoot as never,
|
||||
);
|
||||
predictor.recordInput([1, 0]); // unacked rightward input at t=1000
|
||||
|
||||
clock = 1100; // replay ~100 ms forward
|
||||
setFrameTimeMs(1100); // replay ~100 ms forward
|
||||
predictor.update([], 1 / 60);
|
||||
return [
|
||||
predictor.head.center[0],
|
||||
|
|
@ -77,18 +94,21 @@ describe('local prediction reconciliation', () => {
|
|||
});
|
||||
|
||||
it('suppresses prediction while the local player is dead', () => {
|
||||
let clock = 5000;
|
||||
setPredictorClockForTesting(() => clock);
|
||||
setFrameTimeMs(5000);
|
||||
|
||||
const predictor = new LocalCharacterPredictor();
|
||||
const auth = poseAt(200, 200);
|
||||
const t = predictor.recordInput([1, 0]);
|
||||
predictor.acknowledge(t, [0, 0], -Infinity);
|
||||
predictor.acknowledge(t, movementState() as never, -Infinity);
|
||||
predictor.setStrength(80);
|
||||
predictor.setAuthoritative(auth.head as never, auth.leftFoot as never, auth.rightFoot as never);
|
||||
predictor.setAuthoritative(
|
||||
auth.head as never,
|
||||
auth.leftFoot as never,
|
||||
auth.rightFoot as never,
|
||||
);
|
||||
predictor.setAlive(false); // dead, awaiting respawn
|
||||
|
||||
clock = 5200; // input + elapsed time that would otherwise be replayed forward
|
||||
setFrameTimeMs(5200); // input + elapsed time that would otherwise be replayed forward
|
||||
// Even with a valid authoritative pose and pending input, a dead body must
|
||||
// not be predicted/moved — that was the "move while dead" bug.
|
||||
expect(predictor.update([], 1 / 60)).toBe(false);
|
||||
|
|
@ -100,22 +120,19 @@ describe('local prediction reconciliation', () => {
|
|||
// and the local body sat a full one-way trip behind the server.
|
||||
it('replays the whole un-acknowledged span, so a longer trip predicts further', () => {
|
||||
const driveWithUnackedSpan = (ackAgeMs: number) => {
|
||||
let clock = 0;
|
||||
setPredictorClockForTesting(() => clock);
|
||||
|
||||
const predictor = new LocalCharacterPredictor();
|
||||
predictor.setStrength(80);
|
||||
// The key went down well before the acknowledged span and is still held —
|
||||
// the case that used to freeze the acknowledgement, because movement is
|
||||
// only transmitted when it changes.
|
||||
clock = 9_000;
|
||||
setFrameTimeMs(9_000);
|
||||
predictor.recordInput([1, 0]);
|
||||
|
||||
clock = 10_000;
|
||||
setFrameTimeMs(10_000);
|
||||
const auth = poseAt(0, 0);
|
||||
// The server has confirmed input only up to `ackAgeMs` ago; the snapshot
|
||||
// carrying that acknowledgement arrives now.
|
||||
predictor.acknowledge(clock - ackAgeMs, [0, 0], -Infinity);
|
||||
predictor.acknowledge(10_000 - ackAgeMs, movementState() as never, -Infinity);
|
||||
predictor.setAuthoritative(
|
||||
auth.head as never,
|
||||
auth.leftFoot as never,
|
||||
|
|
@ -134,32 +151,30 @@ describe('local prediction reconciliation', () => {
|
|||
// a 40 ms one; when the window was pinned to the snapshot they were equal.
|
||||
expect(longTrip).toBeGreaterThan(shortTrip * 2);
|
||||
});
|
||||
|
||||
// ...but the anchor must not also inherit the AGE of whichever input happened
|
||||
// to be the newest one the server had when it took the snapshot. Input is sent
|
||||
// once per frame, so that age is 0..1 frame depending on where the client's
|
||||
// frames fell between two snapshots, and it walks a sawtooth: measured against
|
||||
// a real server on localhost it ran 5, 16, 12, 8, 3, 14, 9, 1 ms and so on.
|
||||
// Left in the window, it jumped the predicted body by up to a frame of travel
|
||||
// 25 times a second — the local body was choppy on a link with no latency at
|
||||
// all to hide it. The server reports the age with the acknowledgement so the
|
||||
// anchor lands on the snapshot instant instead.
|
||||
// at a fixed cadence, so that age is 0..1 send interval depending on where the
|
||||
// client's sends fell between two snapshots, and it walks a sawtooth: measured
|
||||
// against a real server on localhost it ran 5, 16, 12, 8, 3, 14, 9, 1 ms and so
|
||||
// on. Left in the window, it jumped the predicted body by up to a frame of
|
||||
// travel 25 times a second — the local body was choppy on a link with no
|
||||
// latency at all to hide it. The server reports the age with the
|
||||
// acknowledgement so the anchor lands on the snapshot instant instead.
|
||||
it('cancels the age of the acknowledged input, so the send cadence cannot beat', () => {
|
||||
// Both runs describe the SAME snapshot: taken 20 ms ago, of the same pose.
|
||||
// They differ only in how long before it the server's newest input arrived.
|
||||
const snapshotAgeMs = 20;
|
||||
const drive = (inputAgeMs: number, serverReportsAge: boolean) => {
|
||||
let clock = 0;
|
||||
setPredictorClockForTesting(() => clock);
|
||||
|
||||
const predictor = new LocalCharacterPredictor();
|
||||
predictor.setStrength(80);
|
||||
clock = 9_000;
|
||||
setFrameTimeMs(9_000);
|
||||
predictor.recordInput([1, 0]); // held down throughout
|
||||
|
||||
clock = 10_000;
|
||||
setFrameTimeMs(10_000);
|
||||
predictor.acknowledge(
|
||||
clock - snapshotAgeMs - inputAgeMs,
|
||||
[0, 0],
|
||||
10_000 - snapshotAgeMs - inputAgeMs,
|
||||
movementState() as never,
|
||||
-Infinity,
|
||||
serverReportsAge ? inputAgeMs : 0,
|
||||
);
|
||||
|
|
@ -179,4 +194,57 @@ describe('local prediction reconciliation', () => {
|
|||
// 16 ms moves the body, so the assertion above does not hold vacuously.
|
||||
expect(Math.abs(drive(16, false) - drive(0, false))).toBeGreaterThan(0.5);
|
||||
});
|
||||
|
||||
// The jitter this file exists to catch. A walking body's posture springs lag,
|
||||
// so its head trails the upright position — and the facing direction is NOT
|
||||
// recoverable from such a pose: the angle you get back is tens of degrees off.
|
||||
// The predictor used to derive it that way rather than take the server's, so
|
||||
// every replay opened by twisting the body towards the pose-implied posture,
|
||||
// by an amount that depended on how many ticks the window happened to contain.
|
||||
// The rendered body then advanced a different distance each frame.
|
||||
it('replays the streamed facing direction, not one guessed from the pose', () => {
|
||||
// Head pulled 35 units behind the upright position: the steady-state posture
|
||||
// lag of a body walking to the right. atan2 on this pose gives ~0.57 rad;
|
||||
// the server's own direction is 0.
|
||||
const walkingPose = () => ({
|
||||
head: { center: [-35, 37], radius: HEAD_RADIUS },
|
||||
leftFoot: { center: [-33, -18], radius: FEET_RADIUS },
|
||||
rightFoot: { center: [33, -18], radius: FEET_RADIUS },
|
||||
});
|
||||
|
||||
// One snapshot interval of replay, which is as much as reconciliation ever
|
||||
// asks for on a healthy link.
|
||||
const predictWithDirection = (direction: number) => {
|
||||
const predictor = new LocalCharacterPredictor();
|
||||
predictor.setStrength(80);
|
||||
setFrameTimeMs(0);
|
||||
predictor.recordInput([1, 0]);
|
||||
predictor.acknowledge(0, movementState(direction) as never, -Infinity);
|
||||
const auth = walkingPose();
|
||||
predictor.setAuthoritative(
|
||||
auth.head as never,
|
||||
auth.leftFoot as never,
|
||||
auth.rightFoot as never,
|
||||
);
|
||||
|
||||
// A fresh predictor renders its first pose by snapping, so this is the raw
|
||||
// prediction with no easing laid over it.
|
||||
setFrameTimeMs(40);
|
||||
predictor.update([], 1 / 60);
|
||||
return [predictor.head.center[0], predictor.head.center[1]];
|
||||
};
|
||||
|
||||
const upright = predictWithDirection(0);
|
||||
const asThePoseImplies = predictWithDirection(0.566);
|
||||
const apart = Math.hypot(
|
||||
upright[0] - asThePoseImplies[0],
|
||||
upright[1] - asThePoseImplies[1],
|
||||
);
|
||||
|
||||
// The guess moved the body by a visible amount every 40 ms — and by a
|
||||
// different amount each frame, because the correction it triggers decays
|
||||
// over the replay. If the streamed direction were ignored again the two
|
||||
// would come out identical (this was exactly 0 before the fix).
|
||||
expect(apart).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue