/** * Everybody else, drawn between the things the server said about them. * * A remote body is only ever heard about a few dozen times a second, so it * has to be drawn somewhere in between. The old way was an exponential chase * — `now.x += (x - now.x) * 0.35` every frame — which is wrong twice: it is a * different speed at 60Hz than at 144Hz, and it is a pursuit that never * arrives, so every other player was permanently behind and rubbery. * * This is entity interpolation instead. Each body keeps the last few samples * with the times they landed, and the frame is drawn at `now - delay`, about * a snapshot interval in the past. That moment falls between two samples on * hand, so the body moves at exactly the speed the server moved it and * arrives exactly where the server put it — one interval late, which nobody * can see. A handful of samples rather than strictly two is what buys the * delay a margin: a snapshot that arrives late or early still brackets the * moment being drawn instead of stalling the body. * * When a snapshot really is missed the newest pair's own velocity is carried * forward for one interval and then held: a guess that long is better than a * stall, and a guess longer than that is fiction. The delay follows the rate * snapshots actually arrive at, so the same code is right at 15Hz and at * 30Hz without being told which it is. * * Nothing here is ever sent anywhere: it is a drawing, and the server is * still the only thing that says where anybody is. */ /** The render delay, as a multiple of the interval snapshots arrive at. */ export const LAG = 1.3; /** ...never tighter than this, never looser, in seconds. */ export const LAG_MIN = 0.045; export const LAG_MAX = 0.13; /** How many samples a body keeps: enough to bracket the delay, and no more. */ export const KEEP = 4; /** How fast the measured interval follows the arrivals. */ const INTERVAL_PULL = 0.25; /** A gap this long means the body was gone, not slow: start it again. */ export const GONE = 0.4; /** Faster than anything can run: a respawn, or a body back from nowhere. */ const TOP_SPEED = 400; const clamp = (n, low, high) => (n < low ? low : n > high ? high : n); /** The short way round from one angle to another, in (-π, π]. */ export const shortWay = (from, to) => ((to - from + Math.PI * 3) % (Math.PI * 2)) - Math.PI; /** One timeline for the whole snapshot stream: the bodies, and the rate. */ export const makeTimeline = (interval = 1 / 15) => ({ bodies: new Map(), interval, at: 0 }); /** * A snapshot landed at `at` seconds. The gap since the last one is what the * render delay is built out of; a gap wilder than any real rate (a tab that * was in the background, a reconnect) says nothing about the rate and is * left out of it. */ export function arrive(timeline, at) { const gap = at - timeline.at; if (timeline.at > 0 && gap > 1 / 120 && gap < 0.5) timeline.interval += (gap - timeline.interval) * INTERVAL_PULL; timeline.at = at; return timeline; } /** How far behind the present this timeline is drawn, in seconds. */ export const delay = (timeline) => clamp(timeline.interval * LAG, LAG_MIN, LAG_MAX); /** The moment being drawn, given the clock now. */ export const renderTime = (timeline, now) => now - delay(timeline); const mark = (at, body) => ({ t: at, x: body.x, y: body.y, angle: body.angle }); /** * What the server just said about one body. Out-of-order samples are * dropped; a body that has been away, or that has moved further than anyone * can run, starts fresh rather than sliding across the map to its new place. * * The drawn position lives on the track — one object per body, so a caller * may hold the answer it was given while asking about somebody else. */ export function feed(timeline, id, body, at) { const track = timeline.bodies.get(id); if (!track) { timeline.bodies.set(id, { marks: [mark(at, body)], out: { x: 0, y: 0, angle: 0 } }); return timeline; } const last = track.marks[track.marks.length - 1]; if (at <= last.t) return timeline; const gap = at - last.t; const jumped = Math.hypot(body.x - last.x, body.y - last.y) > TOP_SPEED * gap + 4; if (gap > GONE || jumped) track.marks.length = 0; track.marks.push(mark(at, body)); if (track.marks.length > KEEP) track.marks.shift(); return timeline; } /** * Where a body was at a moment, given the samples it has — ascending in * time, and at least one. * * Between two samples this is a straight walk from one to the other at the * server's own speed. Before the oldest it holds, because running backwards * to meet a moment we have no answer for would be inventing one. After the * newest it carries the last pair's velocity for one interval — no more, * ever — and then holds there. */ export function sample(marks, when, out = { x: 0, y: 0, angle: 0 }) { const last = marks[marks.length - 1]; if (marks.length === 1 || when >= last.t) return beyond(marks, when, out); if (when <= marks[0].t) return at(marks[0], marks[0], 0, out); let i = marks.length - 2; while (i > 0 && marks[i].t > when) i -= 1; const from = marks[i]; const to = marks[i + 1]; const span = to.t - from.t; return at(from, to, span > 0 ? (when - from.t) / span : 1, out); } /** Past the newest sample: one interval of the last pair's velocity, held. */ function beyond(marks, when, out) { const last = marks[marks.length - 1]; const before = marks[marks.length - 2]; if (!before) return at(last, last, 0, out); const span = last.t - before.t; if (span <= 0) return at(last, last, 0, out); return at(before, last, clamp((when - before.t) / span, 1, 2), out); } /** A point `u` of the way from one sample to another; the turn is short. */ function at(from, to, u, out) { out.x = from.x + (to.x - from.x) * u; out.y = from.y + (to.y - from.y) * u; out.angle = from.angle + shortWay(from.angle, to.angle) * u; return out; } /** * Where to draw a body out of a snapshot. One with no samples yet — the * first frame it is seen on — is drawn where the server says, which is the * only honest answer available. */ export function bodyAt(timeline, body, when, out = null) { const track = timeline.bodies.get(body.id); if (!track) return body; return sample(track.marks, when, out ?? track.out); }