GLM
This commit is contained in:
parent
b9274a42c7
commit
5017ff168a
10 changed files with 1004 additions and 2007 deletions
|
|
@ -1,7 +0,0 @@
|
|||
export const hide = (e: HTMLElement, useDisplay = false) => {
|
||||
if (useDisplay) {
|
||||
e.style.display = 'none';
|
||||
} else {
|
||||
e.style.visibility = 'hidden';
|
||||
}
|
||||
};
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { Circle } from 'shared';
|
||||
import { LinearInterpolator } from './linear-interpolator';
|
||||
import { Vec2Interpolator } from './vec2-interpolator';
|
||||
|
||||
export class CircleInterpolator {
|
||||
private center: Vec2Interpolator;
|
||||
private radius: LinearInterpolator;
|
||||
|
||||
constructor(currentValue: Circle) {
|
||||
this.center = new Vec2Interpolator(currentValue.center);
|
||||
this.radius = new LinearInterpolator(currentValue.radius);
|
||||
}
|
||||
|
||||
public addFrame(value: Circle, rateOfChange: Circle) {
|
||||
this.center.addFrame(value.center, rateOfChange.center);
|
||||
this.radius.addFrame(value.radius, rateOfChange.radius);
|
||||
}
|
||||
|
||||
public getValue(deltaTime: number): Circle {
|
||||
return new Circle(this.center.getValue(deltaTime), this.radius.getValue(deltaTime));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { last, mix } from 'shared';
|
||||
import { mix } from 'shared';
|
||||
import { serverTimeline } from '../server-timeline';
|
||||
|
||||
interface Frame {
|
||||
|
|
@ -7,25 +7,16 @@ interface Frame {
|
|||
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.
|
||||
* 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> = [];
|
||||
|
|
@ -39,7 +30,7 @@ export class LinearInterpolator {
|
|||
|
||||
public addFrame(value: number, rateOfChange: number) {
|
||||
const time = serverTimeline.snapshotTime;
|
||||
const newest = last(this.frames);
|
||||
const newest = this.frames.length > 0 ? this.frames[this.frames.length - 1] : null;
|
||||
const wasCoasting = this.isCoasting;
|
||||
|
||||
if (newest && time <= newest.time) {
|
||||
|
|
@ -56,8 +47,6 @@ export class LinearInterpolator {
|
|||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
import { LinearInterpolator } from './linear-interpolator';
|
||||
|
||||
export class Vec2Interpolator {
|
||||
private x: LinearInterpolator;
|
||||
private y: LinearInterpolator;
|
||||
|
||||
constructor(currentValue: vec2) {
|
||||
this.x = new LinearInterpolator(currentValue.x);
|
||||
this.y = new LinearInterpolator(currentValue.y);
|
||||
}
|
||||
|
||||
public addFrame(value: vec2, rateOfChange: vec2) {
|
||||
this.x.addFrame(value.x, rateOfChange.x);
|
||||
this.y.addFrame(value.y, rateOfChange.y);
|
||||
}
|
||||
|
||||
public getValue(deltaTime: number): vec2 {
|
||||
return vec2.fromValues(this.x.getValue(deltaTime), this.y.getValue(deltaTime));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,3 @@
|
|||
import { vec2 } from 'gl-matrix';
|
||||
|
||||
export class Pointer {
|
||||
private static displayPosition: vec2 | null = null;
|
||||
|
||||
public static setDisplayPosition(x: number, y: number): void {
|
||||
Pointer.displayPosition = vec2.fromValues(x, y);
|
||||
}
|
||||
|
||||
public static getDisplayPosition(): vec2 | null {
|
||||
return Pointer.displayPosition;
|
||||
}
|
||||
}
|
||||
export const pointer = { displayPosition: null as vec2 | null };
|
||||
|
|
|
|||
|
|
@ -9,8 +9,7 @@ import {
|
|||
resolveCircleMovement,
|
||||
} from 'shared';
|
||||
|
||||
// What the predictor needs to know about a planet to collide and be pulled by
|
||||
// it. The client reads this off its PlanetViews.
|
||||
// What the predictor needs to know about a planet to collide and be pulled by it.
|
||||
export interface PredictablePlanet {
|
||||
id: Id;
|
||||
vertices: Array<vec2>;
|
||||
|
|
@ -20,9 +19,7 @@ export interface PredictablePlanet {
|
|||
rotationSpeed: number;
|
||||
}
|
||||
|
||||
// A planet collision/gravity surface whose rotation can be advanced during
|
||||
// replay, so its outline turns in lockstep with the body the carry term moves —
|
||||
// exactly as the server steps the planet before the character each tick.
|
||||
// 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;
|
||||
|
|
@ -71,18 +68,12 @@ class PlanetSurface implements GroundSurface {
|
|||
}
|
||||
}
|
||||
|
||||
// The planets-only collision world the local predictor runs against. It holds
|
||||
// persistent surfaces keyed by planet id (so a `currentPlanet` reference stays
|
||||
// valid across frames) and never dispatches collision reactions — damage,
|
||||
// scoring and the like are server-authoritative.
|
||||
// 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.
|
||||
export class ClientCharacterWorld implements CharacterWorld {
|
||||
private surfaces = new Map<Id, PlanetSurface>();
|
||||
private ordered: Array<PlanetSurface> = [];
|
||||
|
||||
// Refresh from the current PlanetViews. 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.
|
||||
// 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) {
|
||||
|
|
@ -104,10 +95,7 @@ export class ClientCharacterWorld implements CharacterWorld {
|
|||
.map((e) => e[1]);
|
||||
}
|
||||
|
||||
// Advance every planet's collision frame by one replay substep, so surfaces
|
||||
// and the carried body rotate together. The surfaces are re-synced to the
|
||||
// newest snapshot rotation each frame (see sync), so the replay only ever
|
||||
// steps forward from there.
|
||||
// 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);
|
||||
|
|
|
|||
|
|
@ -5,16 +5,9 @@ interface InputSample {
|
|||
direction: vec2;
|
||||
}
|
||||
|
||||
// Keep a little over a second of input — far more than any sane reconciliation
|
||||
// window — so a brief stall (or a backgrounded tab catching up) can still be
|
||||
// replayed, while old samples are pruned to bound memory.
|
||||
const retainMs = 1500;
|
||||
|
||||
// A timeline of the local player's movement directions in client-clock time.
|
||||
// Each generated MoveActionCommand is stamped with the time this hands out, and
|
||||
// the same sample is recorded here so the predictor replays exactly the input
|
||||
// the server will eventually receive. Direction is piecewise-constant: the
|
||||
// active direction at any instant is the most recent sample at or before it.
|
||||
// Timeline of local movement directions; the predictor replays exactly the input the server receives.
|
||||
export class InputHistory {
|
||||
private samples: Array<InputSample> = [];
|
||||
|
||||
|
|
@ -27,8 +20,6 @@ export class InputHistory {
|
|||
}
|
||||
}
|
||||
|
||||
// The held direction at `timeMs`: the latest sample at or before it, or zero
|
||||
// before any input exists.
|
||||
public directionAt(timeMs: number): vec2 {
|
||||
let direction = vec2.create();
|
||||
for (const sample of this.samples) {
|
||||
|
|
|
|||
|
|
@ -14,49 +14,26 @@ import {
|
|||
import { ClientCharacterWorld, PredictablePlanet } from './client-character-world';
|
||||
import { InputHistory } from './input-history';
|
||||
|
||||
// The server's fixed tick, read from the same place the server reads it — the
|
||||
// replay integrates over this dt, so a comment promising they match is not
|
||||
// enough.
|
||||
// Must match the server's tick source — the replay integrates over this dt.
|
||||
const stepSeconds = settings.targetPhysicsDeltaTimeInSeconds;
|
||||
const stepMs = stepSeconds * 1000;
|
||||
|
||||
// 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.
|
||||
// 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;
|
||||
|
||||
// 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
|
||||
// instead of grinding through hundreds of steps. Comfortably covers the replay
|
||||
// a 300 ms round trip needs; beyond that the body simply trails a little.
|
||||
const maxReplayMs = 400;
|
||||
|
||||
// Render-side easing of the correction the reconciliation produces each frame,
|
||||
// so a snapshot that disagrees with the prediction is smoothed out instead of
|
||||
// popping. Short, so the local player still feels immediate.
|
||||
const smoothSeconds = 0.06;
|
||||
|
||||
// A correction bigger than this isn't prediction error — it's a respawn,
|
||||
// teleport, or a server-side impulse (leap / slingshot / recoil / death throw)
|
||||
// the predictor doesn't model. Snap to it rather than gliding across the gap.
|
||||
const snapDistance = 250;
|
||||
|
||||
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.
|
||||
// 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,
|
||||
|
|
@ -65,68 +42,22 @@ const makeBody = (center: vec2, radius: number, normal: vec2): PhysicsBody => ({
|
|||
restitution: 0,
|
||||
});
|
||||
|
||||
// Predicts the local player's character so it responds to input immediately,
|
||||
// instead of lagging ~100 ms + RTT behind like the interpolated remote objects.
|
||||
// Each frame it resets to the latest authoritative snapshot and replays the
|
||||
// player's own un-acknowledged input through the SAME movement simulation the
|
||||
// server runs (shared/stepCharacterMovement), then eases the rendered pose
|
||||
// toward the result. Discrete server-side impulses it can't model show up as a
|
||||
// large correction and snap rather than rubber-band.
|
||||
export class LocalCharacterPredictor {
|
||||
private readonly inputHistory = new InputHistory();
|
||||
private readonly world = new ClientCharacterWorld();
|
||||
|
||||
private authoritative?: { head: Circle; leftFoot: Circle; rightFoot: Circle };
|
||||
// Client-clock instant the authoritative pose below belongs to, less one
|
||||
// one-way trip — which is exactly the lead the local body should have, since
|
||||
// the input the player is giving right now will not reach the server for that
|
||||
// long. Replaying from here reproduces every input the server has not applied
|
||||
// yet, so the body responds immediately at any latency.
|
||||
//
|
||||
// It is built from BOTH halves of the acknowledgement: the client-clock time
|
||||
// of the newest input the server had applied, plus how long after that the
|
||||
// snapshot was actually taken. Both halves are needed.
|
||||
//
|
||||
// Anchoring on the acknowledged input time ALONE beats against the send
|
||||
// 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
|
||||
// real latency. This anchor degenerates to arrival on a zero-latency link and
|
||||
// grows into full compensation as latency rises.
|
||||
//
|
||||
// A held key sends no fresh movement, which would freeze the acknowledgement
|
||||
// altogether; that is fixed at the source, by closing every outgoing batch
|
||||
// with a ClientHeartbeatCommand.
|
||||
// Replay anchor: built from both halves of the ack (input time + ackAge), advances forward only.
|
||||
private replayAnchorMs?: number;
|
||||
// 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 movement.bodyVelocity;
|
||||
// only newer ones are replayed, so a leap is never applied twice.
|
||||
// Last leap the server folded into streamed momentum; newer ones only, so a leap is never applied twice.
|
||||
private lastLeapAckMs = -Infinity;
|
||||
// Latest streamed shooting-strength, to gate predicted leaps as the server does.
|
||||
private currentStrength = settings.playerMaxStrength;
|
||||
|
||||
// Whether the local body is alive on the server. While dead (awaiting respawn)
|
||||
// prediction is suppressed so the corpse/ghost can't keep walking in response
|
||||
// to input — the server ignores a dead player's movement, so a predicted body
|
||||
// that still moved would be a pure client-side desync.
|
||||
// Dead players don't predict — the server ignores their input, so moving would desync.
|
||||
private alive = true;
|
||||
|
||||
// The eased, rendered pose handed to the view.
|
||||
private renderHead = new Circle(vec2.create(), headRadius);
|
||||
private renderLeftFoot = new Circle(vec2.create(), feetRadius);
|
||||
private renderRightFoot = new Circle(vec2.create(), feetRadius);
|
||||
|
|
@ -142,8 +73,6 @@ export class LocalCharacterPredictor {
|
|||
return this.renderRightFoot;
|
||||
}
|
||||
|
||||
// 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(frameTimeMs);
|
||||
this.inputHistory.record(direction, timeMs);
|
||||
|
|
@ -156,8 +85,7 @@ export class LocalCharacterPredictor {
|
|||
lastLeapClientTimeMs: number,
|
||||
ackAgeMs = 0,
|
||||
): void {
|
||||
// Inputs only advance the acknowledgement forward; the movement state and
|
||||
// leap boundary always adopt the latest authoritative values.
|
||||
// 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;
|
||||
|
|
@ -170,10 +98,7 @@ export class LocalCharacterPredictor {
|
|||
this.authoritative = { head, leftFoot, rightFoot };
|
||||
}
|
||||
|
||||
// The player pressed leap; remember when, so the replay applies the same
|
||||
// impulse the server will. Recorded regardless of whether the server accepts
|
||||
// it — a rejected leap (no strength/cooldown) self-corrects via the streamed
|
||||
// authoritative momentum.
|
||||
// Recorded regardless of acceptance — a rejected leap self-corrects via streamed momentum.
|
||||
public recordLeap(): number {
|
||||
const timeMs = Math.round(frameTimeMs);
|
||||
this.leapHistory.push(timeMs);
|
||||
|
|
@ -212,10 +137,7 @@ export class LocalCharacterPredictor {
|
|||
);
|
||||
}
|
||||
|
||||
// During spawn-in and death the server freezes walking and only scales the
|
||||
// body (CharacterPhysical.step returns early), so its head radius is below
|
||||
// nominal. Predicting then would walk the body off a position the server is
|
||||
// holding still — let interpolation show the animation instead.
|
||||
// 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 &&
|
||||
|
|
@ -223,13 +145,8 @@ export class LocalCharacterPredictor {
|
|||
);
|
||||
}
|
||||
|
||||
// Run one frame of prediction. Returns true if it produced a rendered pose the
|
||||
// caller should use (otherwise fall back to interpolation). `planets` is the
|
||||
// current collision world; `frameSeconds` is the render delta.
|
||||
public update(planets: Array<PredictablePlanet>, frameSeconds: number): boolean {
|
||||
if (!this.alive || !this.canPredict || this.isAnimatingInOrOut) {
|
||||
// Resume from the authoritative pose with a snap rather than gliding from
|
||||
// a stale rendered one.
|
||||
this.hasRender = false;
|
||||
return false;
|
||||
}
|
||||
|
|
@ -250,21 +167,14 @@ export class LocalCharacterPredictor {
|
|||
const auth = this.authoritative!;
|
||||
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.
|
||||
// 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 windowMs = Math.max(0, now - startMs);
|
||||
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.
|
||||
// Full authoritative state — replay is a pure function of (snapshot, input, window) and cannot drift.
|
||||
const state: CharacterMovementState = {
|
||||
// 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,
|
||||
|
|
@ -279,22 +189,14 @@ export class LocalCharacterPredictor {
|
|||
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.
|
||||
// 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]),
|
||||
};
|
||||
|
||||
// The planet collision frames were synced (in update(), just before this)
|
||||
// to the NEWEST snapshot's rotation — the same instant the authoritative
|
||||
// body pose is from — so the body and the surface start the replay at the
|
||||
// same phase. The loop below advances the surfaces FORWARD in lockstep with
|
||||
// the body's carry from that shared phase, so no rewind is needed (and the
|
||||
// persistent surfaces are re-synced next frame, so this never accumulates).
|
||||
// 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 leapStrengthCost, so a burst
|
||||
// of leaps in one replay window is gated by the running strength rather than
|
||||
// a single up-front affordability check.
|
||||
// Mirror the server: each accepted leap spends strength, so a burst is gated by running strength.
|
||||
let availableStrength = this.currentStrength;
|
||||
let lastLeapMs = -Infinity;
|
||||
|
||||
|
|
@ -305,15 +207,11 @@ export class LocalCharacterPredictor {
|
|||
stepCharacterMovement(state, this.world, input, stepSeconds);
|
||||
this.world.advance(stepSeconds);
|
||||
|
||||
// A leap issued during this step launches now (the next step injects the
|
||||
// momentum), gated like the server: on a surface, off cooldown, with
|
||||
// strength. applyLeapImpulse is a no-op off-surface.
|
||||
for (const leapMs of this.leapHistory) {
|
||||
if (
|
||||
leapMs >= t &&
|
||||
leapMs < t + stepMs &&
|
||||
// Only leaps the server hasn't yet folded into the seed, so a leap
|
||||
// is never both seeded and replayed.
|
||||
// Only leaps the server hasn't seeded — never both seeded and replayed.
|
||||
leapMs > this.lastLeapAckMs &&
|
||||
state.currentPlanet &&
|
||||
leapMs - lastLeapMs >= cooldownMs &&
|
||||
|
|
@ -328,10 +226,7 @@ export class LocalCharacterPredictor {
|
|||
t += stepMs;
|
||||
}
|
||||
|
||||
// Final sub-tick of the leftover (< stepMs) so the predicted pose is a
|
||||
// continuous function of the window length rather than advancing in 5 ms
|
||||
// quanta — the floor() above would otherwise surface that as a per-frame
|
||||
// wobble on top of the ~16.7 ms render cadence.
|
||||
// Sub-tick the remainder so the pose is continuous in the window length, not quantized to stepMs.
|
||||
if (remainderSeconds > 0) {
|
||||
tickPlanetDetachment(state, remainderSeconds);
|
||||
stepCharacterMovement(
|
||||
|
|
|
|||
|
|
@ -1,27 +1,15 @@
|
|||
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.
|
||||
* 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;
|
||||
|
|
@ -29,12 +17,10 @@ class ServerTimeline {
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
export const show = (e: HTMLElement, useDisplay = false, normalDisplayValue?: string) => {
|
||||
if (useDisplay) {
|
||||
e.style.display = normalDisplayValue!;
|
||||
} else {
|
||||
e.style.visibility = 'inherit';
|
||||
}
|
||||
};
|
||||
Loading…
Reference in a new issue