/** * Where you are, drawn at the frame rate. * * Everybody else in a snapshot is smoothed toward the last position the * server sent, which is right for a body you only hear about fifteen times a * second. Doing it to your own body is what made the view skip: the camera * was always being dragged toward a position taken sixty-six milliseconds * ago, and against a wall the server kept correcting it besides. * * So your own body is run forward here instead, from your own keys, through * the same step-and-slide the server uses (`src/glide.ts` — one copy, or the * guess fights the answer at every wall). The server is still the only thing * that decides where you are: every snapshot leans the drawn body back * toward its answer, and a real disagreement is taken outright. This is a * drawing optimisation and nothing here is ever sent anywhere. */ import { circleBlocked, slideCircle } from "../src/glide.ts"; /** Past this much disagreement the server simply wins: a spawn, a teleport. */ export const SNAP = 40; /** How fast the rest of a disagreement is leaned off, a second. */ export const PULL = 12; /** How far ahead of a snapshot your own input is ever carried, in seconds. */ export const LEAD_MAX = 0.25; /** * How long your own keys take to show in the server's answer: a send every * 50ms, a hop each way, a tick. The drawn body is allowed this much of a * lead over the answer without being called wrong, in seconds of running — * and a disagreement inside it is leaned off gently rather than at PULL, * because most of what is inside it is not a disagreement at all but the * lag itself, which a hard pull turns into a yank at every start and stop. */ export const LEAD_SLACK = 0.08; export const SOFT_PULL = 2; /** * `anchored` counts the times the server's word was taken outright: every one * is a jump. `still` is how long no key has been held, in seconds. */ export const makeLocal = () => ({ x: 0, y: 0, ready: false, gap: 0, anchored: 0, still: 0 }); /** Take the server's word for it outright. */ export function anchor(local, at) { local.x = at.x; local.y = at.y; local.ready = true; local.gap = 0; local.anchored += 1; return local; } /** * One frame of your own movement: a direction, the speed the server will * move you at, and the world to slide against — `{ solid, tile, r }`. */ export function advance(local, mx, my, speed, dt, world) { if (!local.ready) return local; const len = Math.hypot(mx, my); if (len > 1) { mx /= len; my /= len; } if (speed <= 0 || (mx === 0 && my === 0) || dt <= 0) return local; const to = slideCircle(local.x, local.y, mx * speed * dt, my * speed * dt, world.r, world.solid, world.tile); local.x = to.x; local.y = to.y; return local; } /** * Where the server's last answer has got to by now. * * A snapshot is a position from a moment ago, and the input you have held * since it was sent has not reached it yet. Rather than a sequence number * the server echoes back, the unacknowledged part of your own movement is * measured in time: the age of the snapshot, capped, walked through the * same slide. Hold nothing and this is the server's answer exactly, which * is the point — it is what the drawn body settles onto. */ export function project(at, mx, my, speed, age, world) { const len = Math.hypot(mx, my); if (len > 1) { mx /= len; my /= len; } const ahead = Math.min(LEAD_MAX, Math.max(0, age)); if (speed <= 0 || ahead === 0 || (mx === 0 && my === 0)) return { x: at.x, y: at.y }; return slideCircle(at.x, at.y, mx * speed * ahead, my * speed * ahead, world.r, world.solid, world.tile); } /** * Lean the drawn body toward the server's answer. A small disagreement — * the width of the guess, a shot of lag — is walked off over a moment; a * large one is not a disagreement but a different place, and is taken. * * With keys held (`mx, my`), the part of the gap along the way you are going * is the lag when the drawn body is ahead: the server has not heard the keys * yet. Inside `slack` that is left exactly alone — pulling it is what made * every start a yank and every stop a drift. Across the way you are going, * or with the server ahead, a gap is real and is leaned off at PULL. Having * just stopped, a gap inside the slack is the server still catching up to * where you stopped: held for the lag, then leaned off gently enough not to * show. */ export function reconcile(local, server, dt, mx = 0, my = 0, slack = 0) { if (!local.ready) return anchor(local, server); const dx = server.x - local.x; const dy = server.y - local.y; local.gap = Math.hypot(dx, dy); if (local.gap > SNAP) return anchor(local, server); const hard = 1 - Math.exp(-PULL * Math.max(0, dt)); const len = Math.hypot(mx, my); if (len === 0) { if (local.gap <= slack && local.still < LEAD_SLACK) return local; const k = local.gap > slack ? hard : 1 - Math.exp(-SOFT_PULL * Math.max(0, dt)); local.x += dx * k; local.y += dy * k; return local; } const ux = mx / len; const uy = my / len; const along = dx * ux + dy * uy; // positive: the server is ahead of the drawing const acrossX = dx - along * ux; const acrossY = dy - along * uy; local.x += acrossX * hard; local.y += acrossY * hard; if (along > 0 || -along > slack) { local.x += ux * along * hard; local.y += uy * along * hard; } return local; } /** * Never further past the answer than the lag. The guess may fill the gap to * where the server's word and your own held keys put you, plus `slack` — * the distance your keys travel before the server has heard them — and no * further: left to run, a guess that moves faster than its target is * corrected finds its own equilibrium somewhere beyond it, which is a claim * about a position rather than a drawing of one. */ export function holdBack(local, target, mx, my, slack = 0) { const len = Math.hypot(mx, my); if (len === 0) return local; const dx = mx / len; const dy = my / len; const over = (local.x - target.x) * dx + (local.y - target.y) * dy - slack; if (over > 0) { local.x -= dx * over; local.y -= dy * over; } return local; } /** A body drawn inside a wall is worse than a body drawn late. */ export const insideWall = (local, world) => circleBlocked(local.x, local.y, world.r, world.solid, world.tile); /** * One frame of your own body, the whole of it: the server's answer `me` * carried `age` seconds forward by the keys `held`, the drawn body leaned * onto that, advanced a frame, and never drawn past it. * * A lean across a corner can put the drawn body inside a wall, and `refused` * is the caller's word for anywhere else the server would not let you stand * (the spawn pen in the buy phase). Both used to take the server's answer, * which is a jump: pressed against a wall and strafing, that fired most * frames and read as a stutter. Holding the last good place is invisible and * just as honest — the next snapshot corrects it anyway. Anchoring is kept * for the one case where holding still leaves the body in a wall. */ export function stepLocal(local, me, held, speed, age, dt, world, refused) { const target = project(me, held.mx, held.my, speed, age, world); const wasX = local.x; const wasY = local.y; const slack = speed * LEAD_SLACK; local.still = held.mx === 0 && held.my === 0 ? local.still + dt : 0; reconcile(local, target, dt, held.mx, held.my, slack); advance(local, held.mx, held.my, speed, dt, world); holdBack(local, target, held.mx, held.my, slack); if (insideWall(local, world) || (refused !== null && refused(local.x, local.y))) { local.x = wasX; local.y = wasY; if (insideWall(local, world)) anchor(local, me); } return local; }