rook/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Draw your own body from a prediction, not from a snapshot
The client had no prediction: it drew the camera from an exponential smooth toward the last snapshot at 15Hz, which is right for a body you only hear about fifteen times a second and wrong for the one whose keys you are holding. static/predict.js runs your own movement forward instead, through src/glide.ts — the same step-and-slide the server resolves walls with, so the guess lands where the answer is about to be rather than fighting it. The server is still the only thing that says where you are. Each frame the last snapshot is carried forward by the input held since it arrived (capped at a quarter second, so a stalled socket cannot run off), the drawn body leans onto that, and it is never drawn past it. Past 40 units of disagreement the server's answer is simply taken, and the buy phase — whose spawn pen is a rule of the match and not of the map — is not predicted at all. Nothing about the guess is ever sent anywhere. The snapshot gains one boolean for your own seat (`defusing`), so the browser runs the prediction at the speed the server is about to use. Co-Authored-By: Claude Opus 5 <[email protected]>
6 files
changed +430
−30
README.md +14 −1
| @@ -37,7 +37,13 @@ server: | ||
| 37 | 37 | `src/arena.ts` by comparing the players either side of a tick — it carries |
| 38 | 38 | names, the gun and an age, and never a position. |
| 39 | 39 | - **Bots** fill every empty seat and use their kit: smokes on the way in, |
| 40 | flashes before the entry, a recon dart, a molly on the planted spike. | |
| 40 | flashes before the entry, a recon dart, a molly on the planted spike. They | |
| 41 | shoot through the same spread everyone does, and they aim like something | |
| 42 | with hands: 200–350ms to react to a target they have just seen, an aim | |
| 43 | cone that opens several degrees on the swing and only settles while they | |
| 44 | hold the same target, and more of both when you are crossing their view | |
| 45 | (`src/aim.ts`). Every roll of it comes from the match's seeded random, so | |
| 46 | a seed replays the same round. | |
| 41 | 47 | |
| 42 | 48 | Agents: Ember, Veil, Sable, Mender, Rook — two basics bought with credits |
| 43 | 49 | (charges last the round), a free signature on a cooldown, and an ultimate |
| @@ -47,6 +53,13 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator. | ||
| 47 | 53 | |
| 48 | 54 | ## What is not |
| 49 | 55 | |
| 56 | - Your own body is *drawn* from a guess — the browser runs your own movement | |
| 57 | forward from your own keys between snapshots, through the same | |
| 58 | step-and-slide the server uses (`src/glide.ts`), and carries the last | |
| 59 | snapshot forward by up to a quarter second of held input. It is a drawing | |
| 60 | optimisation only: nothing is sent, every snapshot leans the drawn body | |
| 61 | back onto the server's answer, and a real disagreement is taken outright. | |
| 62 | Everybody else is still smoothed toward the last position the server sent. | |
| 50 | 63 | - The view is a raycaster: walls are one tile tall and flat-shaded, there is |
| 51 | 64 | no looking up or down, and everything that is not a wall — bodies, the |
| 52 | 65 | spike, ability zones — is a flat billboard scaled by distance and clipped |
src/arena.ts +3
| @@ -264,6 +264,9 @@ export function snapshot(viewerId: string): string { | ||
| 264 | 264 | haste: round2(me.haste), |
| 265 | 265 | spike: me.spike, |
| 266 | 266 | planting: round2(me.planting), |
| 267 | // Your own two ways of being held still, so the browser's prediction | |
| 268 | // of your own body runs at the speed the server is about to use. | |
| 269 | defusing: me.defusing > 0, | |
| 267 | 270 | bot: me.bot, |
| 268 | 271 | }, |
| 269 | 272 | players: seen.map((p) => ({ |
src/client.test.ts +59 −3
| @@ -21,6 +21,10 @@ const base = `http://${server.hostname}:${server.port}`; | ||
| 21 | 21 | // ------------------------------------------------------------ the stub DOM |
| 22 | 22 | |
| 23 | 23 | const drawn: string[] = []; |
| 24 | // Where the canvas was moved to: on the top-down view your own body is an | |
| 25 | // arrow drawn about the origin, so the last translate of a frame is where | |
| 26 | // this client thinks you are. | |
| 27 | const moves: { x: number; y: number }[] = []; | |
| 24 | 28 | const listeners = new Map<string, ((event: unknown) => void)[]>(); |
| 25 | 29 | const frames: ((now: number) => void)[] = []; |
| 26 | 30 | |
| @@ -36,7 +40,10 @@ const context = () => | ||
| 36 | 40 | if (prop in target) return target[prop]; |
| 37 | 41 | if (prop === "fillStyle" || prop === "strokeStyle" || prop === "font" || prop === "textAlign") return ""; |
| 38 | 42 | if (prop === "globalAlpha" || prop === "lineWidth") return 1; |
| 39 | return (...args: unknown[]) => drawn.push(`${prop}(${args.length})`); | |
| 43 | return (...args: unknown[]) => { | |
| 44 | if (prop === "translate") moves.push({ x: Number(args[0]), y: Number(args[1]) }); | |
| 45 | return drawn.push(`${prop}(${args.length})`); | |
| 46 | }; | |
| 40 | 47 | }, |
| 41 | 48 | set: () => true, |
| 42 | 49 | }, |
| @@ -97,6 +104,9 @@ const click = (node: Record<string, unknown>) => { | ||
| 97 | 104 | const press = (code: string, key = "") => { |
| 98 | 105 | for (const fn of listeners.get("keydown") ?? []) fn({ code, key, target: {}, preventDefault: noop }); |
| 99 | 106 | }; |
| 107 | const release = (code: string) => { | |
| 108 | for (const fn of listeners.get("keyup") ?? []) fn({ code }); | |
| 109 | }; | |
| 100 | 110 | |
| 101 | 111 | const nodes = new Map<string, Record<string, unknown>>(); |
| 102 | 112 | for (const id of [ |
| @@ -160,9 +170,9 @@ globalThis.WebSocket = function (url: string) { | ||
| 160 | 170 | return openedSocket; |
| 161 | 171 | } as unknown as typeof WebSocket; |
| 162 | 172 | |
| 163 | const runFrame = () => { | |
| 173 | const runFrame = (at = performance.now()) => { | |
| 164 | 174 | const pending = frames.splice(0, frames.length); |
| 165 | for (const fn of pending) fn(performance.now()); | |
| 175 | for (const fn of pending) fn(at); | |
| 166 | 176 | }; |
| 167 | 177 | const until = async (what: () => boolean, ms = 4000) => { |
| 168 | 178 | const stop = Date.now() + ms; |
| @@ -274,6 +284,52 @@ test("the bundled client connects, paints the HUD and draws a frame", async () = | ||
| 274 | 284 | // And the socket really took a seat in the running match. |
| 275 | 285 | expect(seat.bot).toBe(false); |
| 276 | 286 | |
| 287 | // Your own body is drawn from a guess about your own movement, not from | |
| 288 | // the last thing the server said. With the arena stopped — no snapshot can | |
| 289 | // arrive at all — the drawn body still carries the keys you are holding | |
| 290 | // forward, as far as the cap and no further, and stops there. | |
| 291 | press("KeyV"); // the top-down view, where your own body is an arrow | |
| 292 | for (let i = 0; i < 60 && arena.match().phase !== "live"; i++) arena.advance(1); | |
| 293 | await Bun.sleep(60); | |
| 294 | runFrame(); | |
| 295 | arena.stopArena(); | |
| 296 | // Walk whichever way is open from where the server last put us. | |
| 297 | for (const code of ["KeyW", "KeyA", "KeyS", "KeyD"]) release(code); | |
| 298 | const { blockedCircle } = await import("./map.ts"); | |
| 299 | const clear = ([ | |
| 300 | ["KeyW", 0, -1], | |
| 301 | ["KeyS", 0, 1], | |
| 302 | ["KeyA", -1, 0], | |
| 303 | ["KeyD", 1, 0], | |
| 304 | ] as const).find(([, dx, dy]) => | |
| 305 | [20, 40, 60, 80].every((step) => !blockedCircle(seat.x + dx * step, seat.y + dy * step, 16)), | |
| 306 | )!; | |
| 307 | expect(clear).toBeDefined(); | |
| 308 | press(clear[0]); | |
| 309 | const arrow = () => moves[moves.length - 1]!; | |
| 310 | const startedAt = { x: seat.x, y: seat.y }; | |
| 311 | const scale = 900 / (30 * 80); // canvas pixels to a world unit | |
| 312 | const t0 = performance.now(); | |
| 313 | for (let i = 1; i <= 10; i++) { | |
| 314 | moves.length = 0; | |
| 315 | runFrame(t0 + i * 40); | |
| 316 | } | |
| 317 | const drawnAt = arrow(); | |
| 318 | // The drawn body is ahead of the server's last word by the input held | |
| 319 | // since it was sent — a quarter of a second of running at most, and then | |
| 320 | // it waits, because past that a guess would be a claim. Smoothing toward | |
| 321 | // a snapshot that cannot arrive would have gone nowhere at all. | |
| 322 | const lead = (drawnAt.x / scale - seat.x) * clear[1] + (drawnAt.y / scale - seat.y) * clear[2]; | |
| 323 | const sideways = (drawnAt.x / scale - seat.x) * clear[2] - (drawnAt.y / scale - seat.y) * clear[1]; | |
| 324 | expect(lead).toBeGreaterThan(30); | |
| 325 | expect(lead).toBeLessThan(0.25 * 250 + 10); | |
| 326 | expect(Math.abs(sideways)).toBeLessThan(6); | |
| 327 | // Nothing on the server moved: this is a drawing, and the match is still | |
| 328 | // the only thing that says where anybody is. | |
| 329 | expect(seat.x).toBe(startedAt.x); | |
| 330 | expect(seat.y).toBe(startedAt.y); | |
| 331 | arena.startArena(); | |
| 332 | ||
| 277 | 333 | // This test's loopback address is shared with every other test in the |
| 278 | 334 | // run; close the socket so the address is free for the next real |
| 279 | 335 | // connection rather than left holding a seat for the rest of the suite. |
src/predict.test.ts added +151
| @@ -0,0 +1,151 @@ | ||
| 1 | /** | |
| 2 | * The browser's guess about where you are, checked against the server's | |
| 3 | * answer: the same maths, the same wall, and a disagreement that closes. | |
| 4 | */ | |
| 5 | ||
| 6 | import { expect, test } from "bun:test"; | |
| 7 | import { BODY_R, moveSpeed } from "./glide.ts"; | |
| 8 | import { moveCircle, solidTile, TILE } from "./map.ts"; | |
| 9 | import { advance, anchor, holdBack, insideWall, LEAD_MAX, makeLocal, project, reconcile, SNAP } from "../static/predict.js"; | |
| 10 | ||
| 11 | // The world the browser builds out of map.json, which is the map the server | |
| 12 | // plays on: the same rows, the same tile, the same body. | |
| 13 | const world = { solid: solidTile, tile: TILE, r: BODY_R }; | |
| 14 | ||
| 15 | test("nothing is drawn until the server has said where you are", () => { | |
| 16 | const local = makeLocal(); | |
| 17 | expect(local.ready).toBe(false); | |
| 18 | advance(local, 1, 0, 250, 1 / 60, world); | |
| 19 | expect(local).toMatchObject({ x: 0, y: 0 }); | |
| 20 | reconcile(local, { x: 1200, y: 640 }, 1 / 60); | |
| 21 | expect(local).toMatchObject({ x: 1200, y: 640, ready: true }); | |
| 22 | }); | |
| 23 | ||
| 24 | test("a frame of movement is the speed it was given, in the direction it was given", () => { | |
| 25 | const local = anchor(makeLocal(), { x: 1200, y: 640 }); | |
| 26 | advance(local, 1, 0, 250, 1 / 50, world); | |
| 27 | expect(local.x).toBeCloseTo(1205, 6); | |
| 28 | expect(local.y).toBeCloseTo(640, 6); | |
| 29 | // A diagonal is a direction, not a speed boost. | |
| 30 | const diagonal = anchor(makeLocal(), { x: 1200, y: 640 }); | |
| 31 | advance(diagonal, 1, 1, 250, 1 / 50, world); | |
| 32 | expect(Math.hypot(diagonal.x - 1200, diagonal.y - 640)).toBeCloseTo(5, 6); | |
| 33 | // Standing still, planted, or stopped to defuse: nothing moves. | |
| 34 | const still = anchor(makeLocal(), { x: 1200, y: 640 }); | |
| 35 | advance(still, 0, 0, 250, 1 / 50, world); | |
| 36 | advance(still, 1, 0, moveSpeed({ stopped: true }), 1 / 50, world); | |
| 37 | expect(still).toMatchObject({ x: 1200, y: 640 }); | |
| 38 | }); | |
| 39 | ||
| 40 | test("the guess resolves a wall exactly the way the server does", () => { | |
| 41 | // Every open tile of the real map, walked into from eight directions: the | |
| 42 | // browser's step and the server's land in the same place, or the camera | |
| 43 | // spends the round being dragged off whatever it guessed. | |
| 44 | const rows = 19; | |
| 45 | const cols = 30; | |
| 46 | let tried = 0; | |
| 47 | for (let row = 1; row < rows - 1; row++) | |
| 48 | for (let col = 1; col < cols - 1; col++) { | |
| 49 | if (solidTile(col, row)) continue; | |
| 50 | const x = col * TILE + TILE / 2; | |
| 51 | const y = row * TILE + TILE / 2; | |
| 52 | for (const [mx, my] of [ | |
| 53 | [1, 0], | |
| 54 | [-1, 0], | |
| 55 | [0, 1], | |
| 56 | [0, -1], | |
| 57 | [0.7, 0.7], | |
| 58 | [-0.7, 0.7], | |
| 59 | [0.7, -0.7], | |
| 60 | [-0.7, -0.7], | |
| 61 | ]) { | |
| 62 | const local = anchor(makeLocal(), { x, y }); | |
| 63 | advance(local, mx, my, 250, 1 / 15, world); | |
| 64 | const server = moveCircle(x, y, mx * 250 * (1 / 15), my * 250 * (1 / 15), BODY_R); | |
| 65 | expect(local.x).toBeCloseTo(server.x, 9); | |
| 66 | expect(local.y).toBeCloseTo(server.y, 9); | |
| 67 | tried += 1; | |
| 68 | } | |
| 69 | } | |
| 70 | expect(tried).toBeGreaterThan(1000); | |
| 71 | }); | |
| 72 | ||
| 73 | test("a disagreement is leaned off, not jumped", () => { | |
| 74 | const local = anchor(makeLocal(), { x: 1200, y: 640 }); | |
| 75 | reconcile(local, { x: 1210, y: 640 }, 1 / 60); | |
| 76 | expect(local.gap).toBeCloseTo(10, 6); | |
| 77 | // A frame moves a fraction of the way, never the whole way. | |
| 78 | expect(local.x).toBeGreaterThan(1200); | |
| 79 | expect(local.x).toBeLessThan(1203); | |
| 80 | // Held there, it closes. | |
| 81 | for (let i = 0; i < 60; i++) reconcile(local, { x: 1210, y: 640 }, 1 / 60); | |
| 82 | expect(local.x).toBeCloseTo(1210, 2); | |
| 83 | }); | |
| 84 | ||
| 85 | test("a real disagreement is taken outright: the server is the answer", () => { | |
| 86 | const local = anchor(makeLocal(), { x: 1200, y: 640 }); | |
| 87 | // A spawn, a teleport, a seat handed to somebody else mid-round. | |
| 88 | reconcile(local, { x: 1200 + SNAP + 1, y: 640 }, 1 / 60); | |
| 89 | expect(local.x).toBe(1200 + SNAP + 1); | |
| 90 | expect(local.gap).toBe(0); | |
| 91 | }); | |
| 92 | ||
| 93 | test("a snapshot is carried forward by the input held since it was sent", () => { | |
| 94 | const server = { x: 1200, y: 640 }; | |
| 95 | // Holding nothing, the answer is the answer: no lead, nothing invented. | |
| 96 | expect(project(server, 0, 0, 250, 0.1, world)).toEqual({ x: 1200, y: 640 }); | |
| 97 | expect(project(server, 1, 0, 0, 0.1, world)).toEqual({ x: 1200, y: 640 }); | |
| 98 | expect(project(server, 1, 0, 250, 0, world)).toEqual({ x: 1200, y: 640 }); | |
| 99 | // Holding east for 66ms, it is 66ms of running east ahead. | |
| 100 | const ahead = project(server, 1, 0, 250, 1 / 15, world); | |
| 101 | expect(ahead.x - 1200).toBeCloseTo(250 / 15, 6); | |
| 102 | // A connection that stopped talking is not a licence to run off the map. | |
| 103 | const far = project(server, 1, 0, 250, 30, world); | |
| 104 | expect(far.x - 1200).toBeLessThanOrEqual(250 * LEAD_MAX); | |
| 105 | // And the carry is the server's own step-and-slide: never through a wall. | |
| 106 | for (let y = 100; y < 1400; y += 37) { | |
| 107 | const from = { x: 300, y }; | |
| 108 | if (insideWall(from, world)) continue; // not a place anybody can stand | |
| 109 | expect(insideWall(project(from, -1, 0, 250, LEAD_MAX, world), world)).toBe(false); | |
| 110 | } | |
| 111 | }); | |
| 112 | ||
| 113 | test("the guess is never drawn past the answer it is running toward", () => { | |
| 114 | const target = { x: 1300, y: 640 }; | |
| 115 | // Ahead of the target along the way it is going: pulled back onto it. | |
| 116 | const past = anchor(makeLocal(), { x: 1320, y: 640 }); | |
| 117 | holdBack(past, target, 1, 0); | |
| 118 | expect(past.x).toBeCloseTo(1300, 9); | |
| 119 | // Short of it, or across it, or standing still: left alone. | |
| 120 | const short = anchor(makeLocal(), { x: 1280, y: 648 }); | |
| 121 | holdBack(short, target, 1, 0); | |
| 122 | expect(short).toMatchObject({ x: 1280, y: 648 }); | |
| 123 | const still = anchor(makeLocal(), { x: 1320, y: 640 }); | |
| 124 | holdBack(still, target, 0, 0); | |
| 125 | expect(still.x).toBe(1320); | |
| 126 | }); | |
| 127 | ||
| 128 | test("a guess running with the server stays within a stride of it, and never inside a wall", () => { | |
| 129 | // Sixty frames a second guessing, fifteen a second corrected, walking east | |
| 130 | // into the wall that bounds mid. This is the case that used to skip. | |
| 131 | const speed = 250; | |
| 132 | const start = { x: 1200, y: 640 }; | |
| 133 | let server = { ...start }; | |
| 134 | const local = anchor(makeLocal(), start); | |
| 135 | let worst = 0; | |
| 136 | let back = 0; | |
| 137 | let previous = local.x; | |
| 138 | for (let frame = 0; frame < 240; frame++) { | |
| 139 | advance(local, 1, 0, speed, 1 / 60, world); | |
| 140 | // The server ticks at 30Hz and is heard from at 15Hz. | |
| 141 | if (frame % 2 === 0) server = moveCircle(server.x, server.y, speed / 30, 0, BODY_R); | |
| 142 | if (frame % 4 === 0) reconcile(local, server, 1 / 15); | |
| 143 | worst = Math.max(worst, Math.hypot(local.x - server.x, local.y - server.y)); | |
| 144 | // The drawn body never walks backwards: that is what a skip looks like. | |
| 145 | back = Math.min(back, local.x - previous); | |
| 146 | previous = local.x; | |
| 147 | expect(insideWall(local, world)).toBe(false); | |
| 148 | } | |
| 149 | expect(worst).toBeLessThan(BODY_R); | |
| 150 | expect(back).toBeGreaterThan(-0.01); | |
| 151 | }); |
static/predict.js added +115
| @@ -0,0 +1,115 @@ | ||
| 1 | /** | |
| 2 | * Where you are, drawn at the frame rate. | |
| 3 | * | |
| 4 | * Everybody else in a snapshot is smoothed toward the last position the | |
| 5 | * server sent, which is right for a body you only hear about fifteen times a | |
| 6 | * second. Doing it to your own body is what made the view skip: the camera | |
| 7 | * was always being dragged toward a position taken sixty-six milliseconds | |
| 8 | * ago, and against a wall the server kept correcting it besides. | |
| 9 | * | |
| 10 | * So your own body is run forward here instead, from your own keys, through | |
| 11 | * the same step-and-slide the server uses (`src/glide.ts` — one copy, or the | |
| 12 | * guess fights the answer at every wall). The server is still the only thing | |
| 13 | * that decides where you are: every snapshot leans the drawn body back | |
| 14 | * toward its answer, and a real disagreement is taken outright. This is a | |
| 15 | * drawing optimisation and nothing here is ever sent anywhere. | |
| 16 | */ | |
| 17 | ||
| 18 | import { circleBlocked, slideCircle } from "../src/glide.ts"; | |
| 19 | ||
| 20 | /** Past this much disagreement the server simply wins: a spawn, a teleport. */ | |
| 21 | export const SNAP = 40; | |
| 22 | /** How fast the rest of a disagreement is leaned off, a second. */ | |
| 23 | export const PULL = 12; | |
| 24 | /** How far ahead of a snapshot your own input is ever carried, in seconds. */ | |
| 25 | export const LEAD_MAX = 0.25; | |
| 26 | ||
| 27 | export const makeLocal = () => ({ x: 0, y: 0, ready: false, gap: 0 }); | |
| 28 | ||
| 29 | /** Take the server's word for it outright. */ | |
| 30 | export function anchor(local, at) { | |
| 31 | local.x = at.x; | |
| 32 | local.y = at.y; | |
| 33 | local.ready = true; | |
| 34 | local.gap = 0; | |
| 35 | return local; | |
| 36 | } | |
| 37 | ||
| 38 | /** | |
| 39 | * One frame of your own movement: a direction, the speed the server will | |
| 40 | * move you at, and the world to slide against — `{ solid, tile, r }`. | |
| 41 | */ | |
| 42 | export function advance(local, mx, my, speed, dt, world) { | |
| 43 | if (!local.ready) return local; | |
| 44 | const len = Math.hypot(mx, my); | |
| 45 | if (len > 1) { | |
| 46 | mx /= len; | |
| 47 | my /= len; | |
| 48 | } | |
| 49 | if (speed <= 0 || (mx === 0 && my === 0) || dt <= 0) return local; | |
| 50 | const to = slideCircle(local.x, local.y, mx * speed * dt, my * speed * dt, world.r, world.solid, world.tile); | |
| 51 | local.x = to.x; | |
| 52 | local.y = to.y; | |
| 53 | return local; | |
| 54 | } | |
| 55 | ||
| 56 | /** | |
| 57 | * Where the server's last answer has got to by now. | |
| 58 | * | |
| 59 | * A snapshot is a position from a moment ago, and the input you have held | |
| 60 | * since it was sent has not reached it yet. Rather than a sequence number | |
| 61 | * the server echoes back, the unacknowledged part of your own movement is | |
| 62 | * measured in time: the age of the snapshot, capped, walked through the | |
| 63 | * same slide. Hold nothing and this is the server's answer exactly, which | |
| 64 | * is the point — it is what the drawn body settles onto. | |
| 65 | */ | |
| 66 | export function project(at, mx, my, speed, age, world) { | |
| 67 | const len = Math.hypot(mx, my); | |
| 68 | if (len > 1) { | |
| 69 | mx /= len; | |
| 70 | my /= len; | |
| 71 | } | |
| 72 | const ahead = Math.min(LEAD_MAX, Math.max(0, age)); | |
| 73 | if (speed <= 0 || ahead === 0 || (mx === 0 && my === 0)) return { x: at.x, y: at.y }; | |
| 74 | return slideCircle(at.x, at.y, mx * speed * ahead, my * speed * ahead, world.r, world.solid, world.tile); | |
| 75 | } | |
| 76 | ||
| 77 | /** | |
| 78 | * Lean the drawn body toward the server's answer. A small disagreement — | |
| 79 | * the width of the guess, a shot of lag — is walked off over a moment; a | |
| 80 | * large one is not a disagreement but a different place, and is taken. | |
| 81 | */ | |
| 82 | export function reconcile(local, server, dt) { | |
| 83 | if (!local.ready) return anchor(local, server); | |
| 84 | const dx = server.x - local.x; | |
| 85 | const dy = server.y - local.y; | |
| 86 | local.gap = Math.hypot(dx, dy); | |
| 87 | if (local.gap > SNAP) return anchor(local, server); | |
| 88 | const k = 1 - Math.exp(-PULL * Math.max(0, dt)); | |
| 89 | local.x += dx * k; | |
| 90 | local.y += dy * k; | |
| 91 | return local; | |
| 92 | } | |
| 93 | ||
| 94 | /** | |
| 95 | * Never past the answer. The guess may fill the gap to where the server's | |
| 96 | * word and your own held keys put you and no further: left to run, a guess | |
| 97 | * that moves faster than its target is corrected finds its own equilibrium | |
| 98 | * somewhere beyond it, which is a claim about a position rather than a | |
| 99 | * drawing of one. | |
| 100 | */ | |
| 101 | export function holdBack(local, target, mx, my) { | |
| 102 | const len = Math.hypot(mx, my); | |
| 103 | if (len === 0) return local; | |
| 104 | const dx = mx / len; | |
| 105 | const dy = my / len; | |
| 106 | const over = (local.x - target.x) * dx + (local.y - target.y) * dy; | |
| 107 | if (over > 0) { | |
| 108 | local.x -= dx * over; | |
| 109 | local.y -= dy * over; | |
| 110 | } | |
| 111 | return local; | |
| 112 | } | |
| 113 | ||
| 114 | /** A body drawn inside a wall is worse than a body drawn late. */ | |
| 115 | export const insideWall = (local, world) => circleBlocked(local.x, local.y, world.r, world.solid, world.tile); |
static/val.js +88 −26
| @@ -12,7 +12,9 @@ | ||
| 12 | 12 | "use strict"; |
| 13 | 13 | |
| 14 | 14 | import { makeBeeper } from "./beep.js"; |
| 15 | import { BODY_R, moveSpeed } from "../src/glide.ts"; | |
| 15 | 16 | import { drawGun, gunSnapshot, gunTick, makeGun } from "./gun.js"; |
| 17 | import { advance, anchor, holdBack, insideWall, makeLocal, project, reconcile } from "./predict.js"; | |
| 16 | 18 | import { choosePrompt, REACH } from "./prompt.js"; |
| 17 | 19 | import { roster, rosterKey } from "./roster.js"; |
| 18 | 20 | import { AGENT_COLOUR, drawScene, makeScene } from "./scene.js"; |
| @@ -30,6 +32,7 @@ const HELP_SEEN = "val.help.seen"; | ||
| 30 | 32 | |
| 31 | 33 | let map = null; |
| 32 | 34 | let solid = () => true; |
| 35 | let world = null; // what predict.js slides against: the grid, from map.json | |
| 33 | 36 | let snap = null; |
| 34 | 37 | let socket = null; |
| 35 | 38 | let agents = null; |
| @@ -45,8 +48,17 @@ let stick = null; // { id, ox, oy, mx, my } | ||
| 45 | 48 | let aimTouch = null; |
| 46 | 49 | let helpShown = false; |
| 47 | 50 | let last = performance.now(); |
| 51 | let snapAt = last; // when the last snapshot landed: how stale the answer is | |
| 48 | 52 | |
| 49 | 53 | const walk = { x: 0, y: 0 }; |
| 54 | // What this person is doing right now, read once a frame for the drawing and | |
| 55 | // once every 50ms for the server. The same object either way: no allocation | |
| 56 | // in a loop that runs sixty times a second. | |
| 57 | const intent = { mx: 0, my: 0, angle: 0, fire: false, use: false, reload: false }; | |
| 58 | // Your own body, run forward from your own keys between snapshots, and the | |
| 59 | // object the drawing is handed for it. | |
| 60 | const local = makeLocal(); | |
| 61 | const drawnMe = { id: "", x: 0, y: 0, angle: 0 }; | |
| 50 | 62 | const scene = makeScene(); |
| 51 | 63 | const gun = makeGun(); |
| 52 | 64 | const beeper = makeBeeper(); |
| @@ -66,6 +78,7 @@ function connect() { | ||
| 66 | 78 | if (data.t !== "s") return; |
| 67 | 79 | const first = snap === null; |
| 68 | 80 | snap = data; |
| 81 | snapAt = performance.now(); | |
| 69 | 82 | if (first && mode === "fp") yaw = data.me.angle; |
| 70 | 83 | gunSnapshot(gun, data); |
| 71 | 84 | paintHud(data); |
| @@ -206,11 +219,10 @@ view.addEventListener("pointerup", endPointer); | ||
| 206 | 219 | view.addEventListener("pointercancel", endPointer); |
| 207 | 220 | view.addEventListener("contextmenu", (event) => event.preventDefault()); |
| 208 | 221 | |
| 209 | // Twenty times a second the server hears what this person is doing. In the | |
| 210 | // first-person view the keys are read against the way you are facing; in the | |
| 211 | // top-down view they are the compass, the way they always were. | |
| 212 | setInterval(() => { | |
| 213 | if (!snap || !map) return; | |
| 222 | // What this person is doing, in the world's terms. In the first-person view | |
| 223 | // the keys are read against the way you are facing; in the top-down view they | |
| 224 | // are the compass, the way they always were. | |
| 225 | function readIntent() { | |
| 214 | 226 | let ax = 0; |
| 215 | 227 | let ay = 0; |
| 216 | 228 | if (keys.has("KeyW")) ay -= 1; |
| @@ -221,31 +233,31 @@ setInterval(() => { | ||
| 221 | 233 | ax = stick.mx; |
| 222 | 234 | ay = stick.my; |
| 223 | 235 | } |
| 224 | let mx = ax; | |
| 225 | let my = ay; | |
| 226 | let angle; | |
| 236 | intent.mx = ax; | |
| 237 | intent.my = ay; | |
| 227 | 238 | if (mode === "fp") { |
| 228 | if (keys.has("ArrowLeft")) yaw -= 0.045; | |
| 229 | if (keys.has("ArrowRight")) yaw += 0.045; | |
| 230 | angle = yaw; | |
| 239 | intent.angle = yaw; | |
| 231 | 240 | moveVector(ax, ay, yaw, walk); |
| 232 | mx = walk.x; | |
| 233 | my = walk.y; | |
| 241 | intent.mx = walk.x; | |
| 242 | intent.my = walk.y; | |
| 234 | 243 | } else { |
| 235 | 244 | const aim = aimTouch || pointer; |
| 236 | const me = worldToScreen(snap.me); | |
| 237 | angle = aim ? Math.atan2(aim.y - me.y, aim.x - me.x) : snap.me.angle; | |
| 238 | yaw = angle; | |
| 245 | const me = worldToScreen(drawnMe.id ? drawnMe : snap.me); | |
| 246 | intent.angle = aim ? Math.atan2(aim.y - me.y, aim.x - me.x) : snap.me.angle; | |
| 247 | yaw = intent.angle; | |
| 239 | 248 | } |
| 240 | send({ | |
| 241 | t: "i", | |
| 242 | mx, | |
| 243 | my, | |
| 244 | a: angle, | |
| 245 | f: firing || keys.has("Space"), | |
| 246 | u: keys.has("KeyF"), | |
| 247 | r: keys.has("KeyR"), | |
| 248 | }); | |
| 249 | intent.fire = firing || keys.has("Space"); | |
| 250 | intent.use = keys.has("KeyF"); | |
| 251 | intent.reload = keys.has("KeyR"); | |
| 252 | return intent; | |
| 253 | } | |
| 254 | ||
| 255 | // Twenty times a second the server hears it. Intent only — a direction, an | |
| 256 | // angle, a held key — never a position: the guess below is for drawing. | |
| 257 | setInterval(() => { | |
| 258 | if (!snap || !map) return; | |
| 259 | const now = readIntent(); | |
| 260 | send({ t: "i", mx: now.mx, my: now.my, a: now.angle, f: now.fire, u: now.use, r: now.reload }); | |
| 249 | 261 | }, 50); |
| 250 | 262 | |
| 251 | 263 | // ------------------------------------------------------------------ drawing |
| @@ -275,7 +287,49 @@ function smooth(id, x, y, angle) { | ||
| 275 | 287 | return now; |
| 276 | 288 | } |
| 277 | 289 | |
| 278 | const look = (p) => smooth(p.id, p.x, p.y, p.angle); | |
| 290 | /** | |
| 291 | * Everybody else is smoothed toward the last position the server sent. | |
| 292 | * You are not: your own body is the one this browser can run forward | |
| 293 | * honestly, and smoothing it is what dragged the camera backwards. | |
| 294 | */ | |
| 295 | const look = (p) => (predicting() && p.id === snap.me.id ? drawnMe : smooth(p.id, p.x, p.y, p.angle)); | |
| 296 | ||
| 297 | /** | |
| 298 | * Your own movement is only guessed at while the round is live and you are | |
| 299 | * alive. The buy phase pens everybody in their own spawn — a rule of the | |
| 300 | * match, not of the map — and a guess that does not know it would spend the | |
| 301 | * phase being pulled back through the door. | |
| 302 | */ | |
| 303 | const predicting = () => snap !== null && world !== null && snap.phase !== "buy" && snap.me.alive; | |
| 304 | ||
| 305 | /** The server's answer, caught up to now, then this frame of your own keys. */ | |
| 306 | function predict(dt, now) { | |
| 307 | if (!predicting()) { | |
| 308 | local.ready = false; | |
| 309 | drawnMe.id = ""; | |
| 310 | return; | |
| 311 | } | |
| 312 | const held = readIntent(); | |
| 313 | const speed = moveSpeed({ | |
| 314 | haste: snap.me.haste > 0, | |
| 315 | slow: snap.me.slow > 0, | |
| 316 | stopped: snap.me.planting > 0 || snap.me.defusing, | |
| 317 | heavy: snap.me.weapon === "operator", | |
| 318 | }); | |
| 319 | // The snapshot is a moment old and the keys held since have not reached | |
| 320 | // it; carry it that far forward and lean the drawn body onto it. | |
| 321 | const target = project(snap.me, held.mx, held.my, speed, (now - snapAt) / 1000, world); | |
| 322 | reconcile(local, target, dt); | |
| 323 | advance(local, held.mx, held.my, speed, dt, world); | |
| 324 | holdBack(local, target, held.mx, held.my); | |
| 325 | // A lean across a corner could put the drawn body in a wall; the server's | |
| 326 | // answer is never wrong, so take it rather than draw from inside one. | |
| 327 | if (insideWall(local, world)) anchor(local, snap.me); | |
| 328 | drawnMe.id = snap.me.id; | |
| 329 | drawnMe.x = local.x; | |
| 330 | drawnMe.y = local.y; | |
| 331 | drawnMe.angle = mode === "fp" ? yaw : snap.me.angle; | |
| 332 | } | |
| 279 | 333 | |
| 280 | 334 | function frame(now) { |
| 281 | 335 | requestAnimationFrame(frame); |
| @@ -289,6 +343,11 @@ function frame(now) { | ||
| 289 | 343 | drawTopdown(ctx, map, null, look, { x: 0, y: 0, w: view.width, h: view.height }); |
| 290 | 344 | return; |
| 291 | 345 | } |
| 346 | // Turning with the arrow keys belongs to the frame, at a rate a second, | |
| 347 | // rather than to whatever the input timer happens to do. | |
| 348 | if (mode === "fp" && keys.has("ArrowLeft")) yaw -= 0.9 * dt; | |
| 349 | if (mode === "fp" && keys.has("ArrowRight")) yaw += 0.9 * dt; | |
| 350 | predict(dt, now); | |
| 292 | 351 | beeper.update(snap.spike.planted, snap.spike.fuse, now / 1000); |
| 293 | 352 | const eye = look(snap.me); |
| 294 | 353 | const facing = mode === "fp" ? yaw : eye.angle; |
| @@ -606,6 +665,9 @@ Promise.all([fetch("/map.json").then((r) => r.json()), fetch("/agents.json").the | ||
| 606 | 665 | const cols = rows[0].length; |
| 607 | 666 | solid = (col, row) => |
| 608 | 667 | col < 0 || row < 0 || row >= rows.length || col >= cols || rows[row][col] === "#"; |
| 668 | // The same grid the server collides against, so a guess about your own | |
| 669 | // movement lands where the server's answer is about to. | |
| 670 | world = { solid, tile: map.tile, r: BODY_R }; | |
| 609 | 671 | $("stage").dataset.mode = mode; |
| 610 | 672 | fillHelp(); |
| 611 | 673 | resize(); |