even more
This commit is contained in:
parent
82e092ba55
commit
8f50ebdb97
6 changed files with 1842 additions and 3095 deletions
|
|
@ -13,11 +13,6 @@ const blendSeconds = 0.12;
|
|||
|
||||
const maxFrames = 32;
|
||||
|
||||
/**
|
||||
* Replays a server-streamed scalar by interpolating between timestamped
|
||||
* snapshots at the render time; coasts briefly on the streamed rate of change
|
||||
* when the buffer runs dry.
|
||||
*/
|
||||
export class LinearInterpolator {
|
||||
private frames: Array<Frame> = [];
|
||||
private blendOffset = 0;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { LinearInterpolator } from './linear-interpolator';
|
||||
|
||||
// Indexed access, not .x/.y, so it never depends on the vec2 prototype plugin.
|
||||
export class Vec2Interpolator {
|
||||
private x: LinearInterpolator;
|
||||
private y: LinearInterpolator;
|
||||
|
|
|
|||
|
|
@ -4,119 +4,43 @@ import {
|
|||
GroundSurface,
|
||||
Id,
|
||||
PhysicsBody,
|
||||
planetDistance,
|
||||
planetGravity,
|
||||
PlanetBase,
|
||||
resolveCircleMovement,
|
||||
} from 'shared';
|
||||
import { PlanetView } from '../../objects/types/planet-view';
|
||||
|
||||
// What the predictor needs to know about a planet to collide and be pulled by it.
|
||||
export interface PredictablePlanet {
|
||||
id: Id;
|
||||
vertices: Array<vec2>;
|
||||
center: vec2;
|
||||
radius: number;
|
||||
rotation: number;
|
||||
rotationSpeed: number;
|
||||
}
|
||||
|
||||
// A planet surface whose rotation advances during replay, in lockstep with the body — exactly as the server steps the planet before the character each tick.
|
||||
class PlanetSurface implements GroundSurface {
|
||||
public readonly canCollide = true;
|
||||
public readonly isGround = true;
|
||||
public readonly id: Id;
|
||||
public center: vec2;
|
||||
public angularVelocity: number;
|
||||
private vertices: Array<vec2>;
|
||||
private radius: number;
|
||||
private rotation = 0;
|
||||
private cos = 1;
|
||||
private sin = 0;
|
||||
|
||||
constructor(planet: PredictablePlanet) {
|
||||
this.id = planet.id;
|
||||
this.center = planet.center;
|
||||
this.vertices = planet.vertices;
|
||||
this.radius = planet.radius;
|
||||
this.angularVelocity = planet.rotationSpeed;
|
||||
this.setRotation(planet.rotation);
|
||||
}
|
||||
|
||||
public sync(planet: PredictablePlanet) {
|
||||
this.center = planet.center;
|
||||
this.vertices = planet.vertices;
|
||||
this.radius = planet.radius;
|
||||
this.angularVelocity = planet.rotationSpeed;
|
||||
this.setRotation(planet.rotation);
|
||||
}
|
||||
|
||||
private setRotation(rotation: number) {
|
||||
this.rotation = rotation;
|
||||
this.cos = Math.cos(rotation);
|
||||
this.sin = Math.sin(rotation);
|
||||
}
|
||||
|
||||
public advance(deltaTimeInSeconds: number) {
|
||||
this.setRotation(this.rotation + this.angularVelocity * deltaTimeInSeconds);
|
||||
}
|
||||
|
||||
public distance(target: vec2): number {
|
||||
return planetDistance(target, this.vertices, this.center, this.cos, this.sin);
|
||||
}
|
||||
|
||||
public gravityAt(target: vec2): vec2 {
|
||||
return planetGravity(this.center, this.radius, target);
|
||||
}
|
||||
}
|
||||
|
||||
// Planets-only collision world for the predictor. Persistent surfaces keyed by id (currentPlanet stays valid across frames); never dispatches collision reactions — those are server-authoritative.
|
||||
// Planets only, re-seeded from the newest snapshot every frame; never dispatches collisions.
|
||||
export class ClientCharacterWorld implements CharacterWorld {
|
||||
private surfaces = new Map<Id, PlanetSurface>();
|
||||
private ordered: Array<PlanetSurface> = [];
|
||||
private surfaces: Array<PlanetBase> = [];
|
||||
|
||||
// Far planets contribute zero gravity (the falloff clamps to 0 past maxGravityDistance) and never collide, so the whole set can be handed to every query without a range filter. Ordered by id so the (rare) two-surface contact picks a stable surface.
|
||||
public sync(planets: Array<PredictablePlanet>) {
|
||||
const seen = new Set<Id>();
|
||||
for (const planet of planets) {
|
||||
seen.add(planet.id);
|
||||
const existing = this.surfaces.get(planet.id);
|
||||
if (existing) {
|
||||
existing.sync(planet);
|
||||
} else {
|
||||
this.surfaces.set(planet.id, new PlanetSurface(planet));
|
||||
}
|
||||
}
|
||||
for (const id of [...this.surfaces.keys()]) {
|
||||
if (!seen.has(id)) {
|
||||
this.surfaces.delete(id);
|
||||
}
|
||||
}
|
||||
this.ordered = [...this.surfaces.entries()]
|
||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||
.map((e) => e[1]);
|
||||
public sync(planets: Array<PlanetView>) {
|
||||
this.surfaces = planets
|
||||
.map((planet) => {
|
||||
const surface = new PlanetBase(planet.id, planet.vertices);
|
||||
surface.rotation = planet.snapshotRotation;
|
||||
surface.angularVelocity = planet.snapshotRotationSpeed;
|
||||
return surface;
|
||||
})
|
||||
.sort((a, b) => a.id - b.id);
|
||||
}
|
||||
|
||||
// Advance every planet's collision frame by one replay substep, so surfaces and the carried body rotate together. Surfaces are re-synced to the newest snapshot rotation each frame (see sync), so the replay only ever steps forward from there.
|
||||
public advance(deltaTimeInSeconds: number) {
|
||||
for (const surface of this.ordered) {
|
||||
surface.advance(deltaTimeInSeconds);
|
||||
}
|
||||
this.surfaces.forEach((s) => s.advanceRotation(deltaTimeInSeconds));
|
||||
}
|
||||
|
||||
public surfaceById(id: Id | undefined): GroundSurface | undefined {
|
||||
return id == null ? undefined : this.surfaces.get(id);
|
||||
public surfaceById(id: Id | null): GroundSurface | undefined {
|
||||
return this.surfaces.find((s) => s.id === id);
|
||||
}
|
||||
|
||||
public groundsNear(): Array<GroundSurface> {
|
||||
return this.ordered;
|
||||
public groundsNear(_center: vec2, _radius: number): Array<GroundSurface> {
|
||||
return this.surfaces;
|
||||
}
|
||||
|
||||
public stepBody(
|
||||
body: PhysicsBody,
|
||||
deltaTimeInSeconds: number,
|
||||
): GroundSurface | undefined {
|
||||
const { hitObject } = resolveCircleMovement(body, deltaTimeInSeconds, this.ordered);
|
||||
return hitObject && (hitObject as GroundSurface).isGround
|
||||
? (hitObject as GroundSurface)
|
||||
: undefined;
|
||||
const hit = resolveCircleMovement(body, deltaTimeInSeconds, this.surfaces);
|
||||
return hit instanceof PlanetBase ? hit : undefined;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ interface InputSample {
|
|||
|
||||
const retainMs = 1500;
|
||||
|
||||
// Timeline of local movement directions; the predictor replays exactly the input the server receives.
|
||||
export class InputHistory {
|
||||
private samples: Array<InputSample> = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -5,25 +5,26 @@ import {
|
|||
CharacterMovementState,
|
||||
PhysicsBody,
|
||||
settings,
|
||||
clamp,
|
||||
easeVec2,
|
||||
stepCharacterMovement,
|
||||
applyLeapImpulse,
|
||||
tickPlanetDetachment,
|
||||
feetRadius,
|
||||
headRadius,
|
||||
} from 'shared';
|
||||
import { ClientCharacterWorld, PredictablePlanet } from './client-character-world';
|
||||
import { ClientCharacterWorld } from './client-character-world';
|
||||
import { PlanetView } from '../../objects/types/planet-view';
|
||||
import { InputHistory } from './input-history';
|
||||
|
||||
// Must match the server's tick source — the replay integrates over this dt.
|
||||
const stepSeconds = settings.targetPhysicsDeltaTimeInSeconds;
|
||||
const stepMs = stepSeconds * 1000;
|
||||
|
||||
// Frame timestamp, not performance.now() — dispatch jitter would land in the replay window.
|
||||
let frameTimeMs = 0;
|
||||
export const setFrameTimeMs = (timeMs: number): void => {
|
||||
frameTimeMs = timeMs;
|
||||
};
|
||||
export const predictorNowMs = (): number => frameTimeMs;
|
||||
export const clientTimeMs = (): number => Math.round(frameTimeMs);
|
||||
|
||||
const maxReplayMs = 400;
|
||||
|
||||
|
|
@ -33,7 +34,6 @@ const snapDistance = 250;
|
|||
|
||||
const upwards = vec2.fromValues(0, 1);
|
||||
|
||||
// Velocity starts at zero — the sim rebuilds it each tick, so it is not carried state.
|
||||
const makeBody = (center: vec2, radius: number, normal: vec2): PhysicsBody => ({
|
||||
center: vec2.clone(center),
|
||||
radius,
|
||||
|
|
@ -47,15 +47,12 @@ export class LocalCharacterPredictor {
|
|||
private readonly world = new ClientCharacterWorld();
|
||||
|
||||
private authoritative?: { head: Circle; leftFoot: Circle; rightFoot: Circle };
|
||||
// Replay anchor: built from both halves of the ack (input time + ackAge), advances forward only.
|
||||
private replayAnchorMs?: number;
|
||||
private movement?: CharacterMovementSnapshot;
|
||||
private leapHistory: Array<number> = [];
|
||||
// Last leap the server folded into streamed momentum; newer ones only, so a leap is never applied twice.
|
||||
private lastLeapAckMs = -Infinity;
|
||||
private currentStrength = settings.playerMaxStrength;
|
||||
|
||||
// Dead players don't predict — the server ignores their input, so moving would desync.
|
||||
private alive = true;
|
||||
|
||||
private renderHead = new Circle(vec2.create(), headRadius);
|
||||
|
|
@ -74,7 +71,7 @@ export class LocalCharacterPredictor {
|
|||
}
|
||||
|
||||
public recordInput(direction: vec2): number {
|
||||
const timeMs = Math.round(frameTimeMs);
|
||||
const timeMs = clientTimeMs();
|
||||
this.inputHistory.record(direction, timeMs);
|
||||
return timeMs;
|
||||
}
|
||||
|
|
@ -85,7 +82,6 @@ export class LocalCharacterPredictor {
|
|||
lastLeapClientTimeMs: number,
|
||||
ackAgeMs = 0,
|
||||
): void {
|
||||
// The anchor only advances forward; 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;
|
||||
|
|
@ -98,9 +94,8 @@ export class LocalCharacterPredictor {
|
|||
this.authoritative = { head, leftFoot, rightFoot };
|
||||
}
|
||||
|
||||
// Recorded regardless of acceptance — a rejected leap self-corrects via streamed momentum.
|
||||
public recordLeap(): number {
|
||||
const timeMs = Math.round(frameTimeMs);
|
||||
const timeMs = clientTimeMs();
|
||||
this.leapHistory.push(timeMs);
|
||||
const cutoff = timeMs - 1500;
|
||||
while (this.leapHistory.length > 0 && this.leapHistory[0] <= cutoff) {
|
||||
|
|
@ -137,7 +132,6 @@ export class LocalCharacterPredictor {
|
|||
);
|
||||
}
|
||||
|
||||
// While spawn-in/death the server freezes walking (head radius < nominal) — don't predict, let interpolation show it.
|
||||
private get isAnimatingInOrOut(): boolean {
|
||||
return (
|
||||
this.authoritative !== undefined &&
|
||||
|
|
@ -145,7 +139,7 @@ export class LocalCharacterPredictor {
|
|||
);
|
||||
}
|
||||
|
||||
public update(planets: Array<PredictablePlanet>, frameSeconds: number): boolean {
|
||||
public update(planets: Array<PlanetView>, frameSeconds: number): boolean {
|
||||
if (!this.alive || !this.canPredict || this.isAnimatingInOrOut) {
|
||||
this.hasRender = false;
|
||||
return false;
|
||||
|
|
@ -166,14 +160,12 @@ export class LocalCharacterPredictor {
|
|||
private simulate(): CharacterMovementState {
|
||||
const auth = this.authoritative!;
|
||||
const movement = this.movement!;
|
||||
const now = Math.round(frameTimeMs);
|
||||
// Replay unconfirmed input from anchor to now, clamped so a stalled tab can't grind hundreds of steps.
|
||||
const startMs = Math.min(now, Math.max(this.replayAnchorMs!, now - maxReplayMs));
|
||||
const now = clientTimeMs();
|
||||
const startMs = clamp(this.replayAnchorMs!, now - maxReplayMs, now);
|
||||
const windowMs = Math.max(0, now - startMs);
|
||||
const steps = Math.floor(windowMs / stepMs);
|
||||
const remainderSeconds = (windowMs - steps * stepMs) / 1000;
|
||||
|
||||
// Full authoritative state — replay is a pure function of (snapshot, input, window) and cannot drift.
|
||||
const state: CharacterMovementState = {
|
||||
head: makeBody(auth.head.center, auth.head.radius, upwards),
|
||||
leftFoot: makeBody(
|
||||
|
|
@ -189,14 +181,10 @@ export class LocalCharacterPredictor {
|
|||
direction: movement.direction,
|
||||
currentPlanet: this.world.surfaceById(movement.groundPlanetId),
|
||||
secondsSinceOnSurface: movement.secondsSinceOnSurface,
|
||||
// Leaps before the window are baked in; inside it they're re-applied — neither double-counted.
|
||||
bodyVelocity: vec2.fromValues(movement.bodyVelocity[0], movement.bodyVelocity[1]),
|
||||
};
|
||||
|
||||
// Planet frames are synced to the newest snapshot's rotation, then advanced forward in lockstep with the body — no rewind needed.
|
||||
|
||||
const cooldownMs = settings.leapCooldownSeconds * 1000;
|
||||
// Mirror the server: each accepted leap spends strength, so a burst is gated by running strength.
|
||||
let availableStrength = this.currentStrength;
|
||||
let lastLeapMs = -Infinity;
|
||||
|
||||
|
|
@ -211,7 +199,6 @@ export class LocalCharacterPredictor {
|
|||
if (
|
||||
leapMs >= t &&
|
||||
leapMs < t + stepMs &&
|
||||
// Only leaps the server hasn't seeded — never both seeded and replayed.
|
||||
leapMs > this.lastLeapAckMs &&
|
||||
state.currentPlanet &&
|
||||
leapMs - lastLeapMs >= cooldownMs &&
|
||||
|
|
@ -226,7 +213,6 @@ export class LocalCharacterPredictor {
|
|||
t += stepMs;
|
||||
}
|
||||
|
||||
// Sub-tick the remainder so the pose is continuous in the window length, not quantized to stepMs.
|
||||
if (remainderSeconds > 0) {
|
||||
tickPlanetDetachment(state, remainderSeconds);
|
||||
stepCharacterMovement(
|
||||
|
|
@ -254,18 +240,13 @@ export class LocalCharacterPredictor {
|
|||
}
|
||||
|
||||
private easeRenderTo(state: CharacterMovementState, frameSeconds: number): void {
|
||||
const q = 1 - Math.exp(-frameSeconds / smoothSeconds);
|
||||
this.easePart(this.renderHead, state.head, q);
|
||||
this.easePart(this.renderLeftFoot, state.leftFoot, q);
|
||||
this.easePart(this.renderRightFoot, state.rightFoot, q);
|
||||
this.easePart(this.renderHead, state.head, frameSeconds);
|
||||
this.easePart(this.renderLeftFoot, state.leftFoot, frameSeconds);
|
||||
this.easePart(this.renderRightFoot, state.rightFoot, frameSeconds);
|
||||
}
|
||||
|
||||
private easePart(render: Circle, target: PhysicsBody, q: number): void {
|
||||
if (vec2.distance(render.center, target.center) > snapDistance) {
|
||||
vec2.copy(render.center, target.center);
|
||||
} else {
|
||||
vec2.lerp(render.center, render.center, target.center, q);
|
||||
}
|
||||
private easePart(render: Circle, target: PhysicsBody, frameSeconds: number): void {
|
||||
easeVec2(render.center, target.center, frameSeconds, smoothSeconds, snapDistance);
|
||||
render.radius = target.radius;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,35 +6,24 @@ const maxRateAdjustment = 0.25;
|
|||
|
||||
const resyncSeconds = 0.3;
|
||||
|
||||
/**
|
||||
* Playback clock for streamed server state — renders interpolationDelaySeconds
|
||||
* behind the server clock so a newer snapshot is usually available; the cursor
|
||||
* adjusts its rate gently and only snaps after a long divergence.
|
||||
*/
|
||||
class ServerTimeline {
|
||||
private cursor?: number;
|
||||
private newestSnapshotTime?: number;
|
||||
private sinceNewestSnapshot = 0;
|
||||
private _snapshotTime = 0;
|
||||
|
||||
public get snapshotTime(): number {
|
||||
return this._snapshotTime;
|
||||
}
|
||||
public snapshotTime = 0;
|
||||
|
||||
public get renderTime(): number {
|
||||
return this.cursor ?? 0;
|
||||
}
|
||||
|
||||
public onSnapshot(timestamp: number) {
|
||||
this._snapshotTime = timestamp;
|
||||
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;
|
||||
|
|
@ -63,7 +52,7 @@ class ServerTimeline {
|
|||
this.cursor = undefined;
|
||||
this.newestSnapshotTime = undefined;
|
||||
this.sinceNewestSnapshot = 0;
|
||||
this._snapshotTime = 0;
|
||||
this.snapshotTime = 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue