quill/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
The view is yours between snapshots, not the server's to stomp
Every arriving snapshot set `yaw` back to the angle the server last heard about, fifteen times a second, while the mouse moved it at frame rate: the camera was dragged backwards constantly, and worst when you turned fastest. The local angle is now the camera's, and the server's is taken only when it is telling us something we did not say — no view yet, a seat change, a respawn, or an angle matching nothing we sent in the last second. That last test is what the sent log is for: the server echoes our own angle back rounded to a tenth, so an echo is recognisable and a round-start recentring is not. Nothing new is sent; the angle leaves through the same input timer. Co-Authored-By: Claude Opus 5 <[email protected]>
3 files
changed +216
−13
src/yaw.test.ts added +118
| @@ -0,0 +1,118 @@ | ||
| 1 | /** | |
| 2 | * The view angle belongs to the browser between snapshots, and the server | |
| 3 | * gets it back only when it has something to say that we did not. | |
| 4 | */ | |
| 5 | ||
| 6 | import { expect, test } from "bun:test"; | |
| 7 | import { makeYaw, settle, shortWay, told, turn, SENT_TOLERANCE } from "../static/yaw.js"; | |
| 8 | ||
| 9 | const me = (angle: number, extra: Partial<{ id: string; alive: boolean }> = {}) => ({ | |
| 10 | id: "p1", | |
| 11 | alive: true, | |
| 12 | angle, | |
| 13 | ...extra, | |
| 14 | }); | |
| 15 | ||
| 16 | // The server rounds an angle to a tenth of a radian on the way out. | |
| 17 | const echoed = (angle: number) => Math.round(angle * 10) / 10; | |
| 18 | ||
| 19 | test("the first snapshot is where you are looking: there is no view of your own yet", () => { | |
| 20 | const yaw = makeYaw(); | |
| 21 | expect(yaw.ready).toBe(false); | |
| 22 | expect(settle(yaw, me(1.4), 10)).toBeCloseTo(1.4, 6); | |
| 23 | expect(yaw.ready).toBe(true); | |
| 24 | }); | |
| 25 | ||
| 26 | test("a snapshot does not drag the camera back to the angle it was told", () => { | |
| 27 | const yaw = makeYaw(); | |
| 28 | settle(yaw, me(0), 10); | |
| 29 | // A flick: the mouse moves at frame rate, and every angle is announced. | |
| 30 | for (let i = 1; i <= 10; i++) { | |
| 31 | turn(yaw, 0.12); | |
| 32 | told(yaw, yaw.angle, 10 + i * 0.016); | |
| 33 | } | |
| 34 | expect(yaw.angle).toBeCloseTo(1.2, 6); | |
| 35 | // The snapshot is the angle the server last heard — four frames ago. | |
| 36 | settle(yaw, me(echoed(0.72)), 10.17); | |
| 37 | expect(yaw.angle).toBeCloseTo(1.2, 6); | |
| 38 | }); | |
| 39 | ||
| 40 | test("an angle we never sent is a correction, and is taken", () => { | |
| 41 | const yaw = makeYaw(); | |
| 42 | settle(yaw, me(0), 10); | |
| 43 | told(yaw, 0, 10); | |
| 44 | turn(yaw, 0.5); | |
| 45 | told(yaw, 0.5, 10.05); | |
| 46 | // The round starts and the match points everybody at the site. | |
| 47 | settle(yaw, me(-2.1), 10.1); | |
| 48 | expect(yaw.angle).toBeCloseTo(-2.1, 6); | |
| 49 | }); | |
| 50 | ||
| 51 | test("an echo just inside the rounding is ours, one just outside is not", () => { | |
| 52 | const near = makeYaw(); | |
| 53 | settle(near, me(0), 10); | |
| 54 | told(near, 0.5, 10); | |
| 55 | turn(near, 0.4); | |
| 56 | settle(near, me(0.5 + SENT_TOLERANCE / 2), 10.05); | |
| 57 | expect(near.angle).toBeCloseTo(0.4, 6); | |
| 58 | ||
| 59 | const far = makeYaw(); | |
| 60 | settle(far, me(0), 10); | |
| 61 | told(far, 0.5, 10); | |
| 62 | turn(far, 0.4); | |
| 63 | settle(far, me(0.5 + SENT_TOLERANCE * 2), 10.05); | |
| 64 | expect(far.angle).toBeCloseTo(0.5 + SENT_TOLERANCE * 2, 6); | |
| 65 | }); | |
| 66 | ||
| 67 | test("an echo from a second ago has expired: only recent angles are ours", () => { | |
| 68 | const yaw = makeYaw(); | |
| 69 | settle(yaw, me(0), 10); | |
| 70 | told(yaw, 0.5, 10); | |
| 71 | turn(yaw, 0.4); | |
| 72 | settle(yaw, me(0.5), 12); | |
| 73 | expect(yaw.angle).toBeCloseTo(0.5, 6); | |
| 74 | }); | |
| 75 | ||
| 76 | test("a client that has not spoken yet keeps what it has", () => { | |
| 77 | const yaw = makeYaw(); | |
| 78 | settle(yaw, me(0), 10); | |
| 79 | turn(yaw, 0.8); | |
| 80 | // No input has been sent between the two snapshots; the server can only be | |
| 81 | // repeating what it already knew, so there is nothing here to take. | |
| 82 | settle(yaw, me(0), 10.03); | |
| 83 | expect(yaw.angle).toBeCloseTo(0.8, 6); | |
| 84 | }); | |
| 85 | ||
| 86 | test("a respawn and a seat change both point you where the server points you", () => { | |
| 87 | const respawn = makeYaw(); | |
| 88 | settle(respawn, me(0, { alive: false }), 10); | |
| 89 | told(respawn, 0, 10); | |
| 90 | turn(respawn, 1); | |
| 91 | settle(respawn, me(0, { alive: true }), 10.1); | |
| 92 | expect(respawn.angle).toBeCloseTo(0, 6); | |
| 93 | ||
| 94 | const seat = makeYaw(); | |
| 95 | settle(seat, me(0), 10); | |
| 96 | told(seat, 0, 10); | |
| 97 | turn(seat, 1); | |
| 98 | settle(seat, { id: "p2", alive: true, angle: 0 }, 10.1); | |
| 99 | expect(seat.angle).toBeCloseTo(0, 6); | |
| 100 | }); | |
| 101 | ||
| 102 | test("the wrap is taken the short way round", () => { | |
| 103 | expect(shortWay(3.0, -3.0)).toBeCloseTo(0.2831853, 6); | |
| 104 | expect(shortWay(-3.0, 3.0)).toBeCloseTo(-0.2831853, 6); | |
| 105 | // Which means an echo either side of π is still ours. | |
| 106 | const yaw = makeYaw(); | |
| 107 | settle(yaw, me(0), 10); | |
| 108 | told(yaw, Math.PI - 0.01, 10); | |
| 109 | turn(yaw, 0.3); | |
| 110 | settle(yaw, me(-Math.PI + 0.01), 10.05); | |
| 111 | expect(yaw.angle).toBeCloseTo(0.3, 6); | |
| 112 | }); | |
| 113 | ||
| 114 | test("the sent log does not grow without bound", () => { | |
| 115 | const yaw = makeYaw(); | |
| 116 | for (let i = 0; i < 500; i++) told(yaw, i * 0.01, 10 + i * 0.05); | |
| 117 | expect(yaw.sent.length).toBeLessThan(30); | |
| 118 | }); |
static/val.js +23 −13
| @@ -20,6 +20,7 @@ import { roster, rosterKey } from "./roster.js"; | ||
| 20 | 20 | import { AGENT_COLOUR, drawScene, makeScene } from "./scene.js"; |
| 21 | 21 | import { moveVector } from "./steer.js"; |
| 22 | 22 | import { drawTopdown } from "./topdown.js"; |
| 23 | import { aim as aimAt, makeYaw, settle, told, turn } from "./yaw.js"; | |
| 23 | 24 | |
| 24 | 25 | const $ = (id) => document.getElementById(id); |
| 25 | 26 | const view = $("view"); |
| @@ -38,7 +39,10 @@ let socket = null; | ||
| 38 | 39 | let agents = null; |
| 39 | 40 | const shown = new Map(); // smoothed positions, so 15 frames a second look like 60 |
| 40 | 41 | const keys = new Set(); |
| 41 | let yaw = 0; // where this person is looking, theirs to move between snapshots | |
| 42 | // Where this person is looking: the browser's between snapshots, and the | |
| 43 | // server's word taken only when it is saying something we did not. See | |
| 44 | // yaw.js — a snapshot that stomps this is what made looking around choppy. | |
| 45 | const head = makeYaw(); | |
| 42 | 46 | let locked = false; |
| 43 | 47 | let firing = false; |
| 44 | 48 | let mode = COARSE ? "top" : "fp"; // a raycaster with touch look is miserable |
| @@ -76,10 +80,11 @@ function connect() { | ||
| 76 | 80 | socket.onmessage = (event) => { |
| 77 | 81 | const data = JSON.parse(event.data); |
| 78 | 82 | if (data.t !== "s") return; |
| 79 | const first = snap === null; | |
| 80 | 83 | snap = data; |
| 81 | 84 | snapAt = performance.now(); |
| 82 | if (first && mode === "fp") yaw = data.me.angle; | |
| 85 | // The server's angle is taken only when it is a correction, never as the | |
| 86 | // routine echo of the angle we sent it a moment ago. | |
| 87 | settle(head, data.me, snapAt / 1000); | |
| 83 | 88 | gunSnapshot(gun, data); |
| 84 | 89 | paintHud(data); |
| 85 | 90 | maybeHelp(data); |
| @@ -96,7 +101,8 @@ function setMode(next) { | ||
| 96 | 101 | mode = next; |
| 97 | 102 | $("stage").dataset.mode = next; |
| 98 | 103 | if (next !== "fp" && document.pointerLockElement === view) document.exitPointerLock(); |
| 99 | if (snap) yaw = snap.me.angle; | |
| 104 | // The two views share one facing: whichever you leave, you keep looking | |
| 105 | // the way you were looking. The server has nothing to add to that. | |
| 100 | 106 | } |
| 101 | 107 | |
| 102 | 108 | addEventListener("keydown", (event) => { |
| @@ -157,7 +163,7 @@ document.addEventListener("pointerlockchange", () => { | ||
| 157 | 163 | $("stage").dataset.locked = locked ? "1" : ""; |
| 158 | 164 | }); |
| 159 | 165 | addEventListener("mousemove", (event) => { |
| 160 | if (locked) yaw += event.movementX * LOOK; | |
| 166 | if (locked) turn(head, event.movementX * LOOK); | |
| 161 | 167 | }); |
| 162 | 168 | addEventListener("mousedown", (event) => { |
| 163 | 169 | if (locked && event.button === 0) firing = true; |
| @@ -236,15 +242,17 @@ function readIntent() { | ||
| 236 | 242 | intent.mx = ax; |
| 237 | 243 | intent.my = ay; |
| 238 | 244 | if (mode === "fp") { |
| 239 | intent.angle = yaw; | |
| 240 | moveVector(ax, ay, yaw, walk); | |
| 245 | intent.angle = head.angle; | |
| 246 | moveVector(ax, ay, head.angle, walk); | |
| 241 | 247 | intent.mx = walk.x; |
| 242 | 248 | intent.my = walk.y; |
| 243 | 249 | } else { |
| 244 | 250 | const aim = aimTouch || pointer; |
| 245 | 251 | 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; | |
| 252 | // The pointer is this view's aim, and it is ours the same way the mouse | |
| 253 | // is: with no pointer yet, keep the facing rather than the stale answer. | |
| 254 | if (aim) aimAt(head, Math.atan2(aim.y - me.y, aim.x - me.x)); | |
| 255 | intent.angle = head.angle; | |
| 248 | 256 | } |
| 249 | 257 | intent.fire = firing || keys.has("Space"); |
| 250 | 258 | intent.use = keys.has("KeyF"); |
| @@ -258,6 +266,8 @@ setInterval(() => { | ||
| 258 | 266 | if (!snap || !map) return; |
| 259 | 267 | const now = readIntent(); |
| 260 | 268 | send({ t: "i", mx: now.mx, my: now.my, a: now.angle, f: now.fire, u: now.use, r: now.reload }); |
| 269 | // What we told it, so its echo is recognisable as ours when it comes back. | |
| 270 | told(head, now.angle, performance.now() / 1000); | |
| 261 | 271 | }, 50); |
| 262 | 272 | |
| 263 | 273 | // ------------------------------------------------------------------ drawing |
| @@ -328,7 +338,7 @@ function predict(dt, now) { | ||
| 328 | 338 | drawnMe.id = snap.me.id; |
| 329 | 339 | drawnMe.x = local.x; |
| 330 | 340 | drawnMe.y = local.y; |
| 331 | drawnMe.angle = mode === "fp" ? yaw : snap.me.angle; | |
| 341 | drawnMe.angle = mode === "fp" ? head.angle : snap.me.angle; | |
| 332 | 342 | } |
| 333 | 343 | |
| 334 | 344 | function frame(now) { |
| @@ -345,12 +355,12 @@ function frame(now) { | ||
| 345 | 355 | } |
| 346 | 356 | // Turning with the arrow keys belongs to the frame, at a rate a second, |
| 347 | 357 | // 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; | |
| 358 | if (mode === "fp" && keys.has("ArrowLeft")) turn(head, -0.9 * dt); | |
| 359 | if (mode === "fp" && keys.has("ArrowRight")) turn(head, 0.9 * dt); | |
| 350 | 360 | predict(dt, now); |
| 351 | 361 | beeper.update(snap.spike.planted, snap.spike.fuse, now / 1000); |
| 352 | 362 | const eye = look(snap.me); |
| 353 | const facing = mode === "fp" ? yaw : eye.angle; | |
| 363 | const facing = mode === "fp" ? head.angle : eye.angle; | |
| 354 | 364 | |
| 355 | 365 | if (mode === "fp" && !mapOpen) { |
| 356 | 366 | drawScene(ctx, scene, { solid, tile: map.tile }, { x: eye.x, y: eye.y, angle: facing }, snap, { |
static/yaw.js added +75
| @@ -0,0 +1,75 @@ | ||
| 1 | /** | |
| 2 | * Where this person is looking. | |
| 3 | * | |
| 4 | * The view angle is the browser's to move: the mouse arrives at frame rate, | |
| 5 | * and a snapshot is a description of a moment already gone. The client used | |
| 6 | * to take `snap.me.angle` on every snapshot, which dragged the camera back | |
| 7 | * to a stale angle thirty times a second — worst exactly when you turned | |
| 8 | * fastest, because that is when the stale angle is furthest away. | |
| 9 | * | |
| 10 | * So the local angle is the camera's, and the server's angle is taken only | |
| 11 | * when it is telling us something we did not say: there is no view yet, the | |
| 12 | * body under us changed, we respawned, or the angle that came back is one we | |
| 13 | * never sent. That last one is what the sent log is for — the server echoes | |
| 14 | * our own angle back, rounded, so an angle matching nothing we sent in the | |
| 15 | * last second came from the match (a round start recentring everybody at the | |
| 16 | * site) rather than from us, and is worth taking. | |
| 17 | * | |
| 18 | * Nothing here is sent anywhere: the angle still leaves through the input | |
| 19 | * timer exactly as it always did. | |
| 20 | */ | |
| 21 | ||
| 22 | /** How long a sent angle is remembered, in seconds. */ | |
| 23 | export const SENT_MEMORY = 1.2; | |
| 24 | /** How close an echo must be to count as ours. The server rounds to 0.1. */ | |
| 25 | export const SENT_TOLERANCE = 0.08; | |
| 26 | ||
| 27 | export const makeYaw = (angle = 0) => ({ angle, id: "", alive: false, ready: false, sent: [] }); | |
| 28 | ||
| 29 | /** The short way round from one angle to another, in (-π, π]. */ | |
| 30 | export const shortWay = (from, to) => ((to - from + Math.PI * 3) % (Math.PI * 2)) - Math.PI; | |
| 31 | ||
| 32 | /** A mouse delta, or an arrow key's share of a second. Ours, always taken. */ | |
| 33 | export function turn(yaw, delta) { | |
| 34 | yaw.angle += delta; | |
| 35 | return yaw.angle; | |
| 36 | } | |
| 37 | ||
| 38 | /** Point it somewhere outright: the top-down view's pointer aim. */ | |
| 39 | export function aim(yaw, angle) { | |
| 40 | yaw.angle = angle; | |
| 41 | return yaw.angle; | |
| 42 | } | |
| 43 | ||
| 44 | /** Remember an angle we told the server about, at a time in seconds. */ | |
| 45 | export function told(yaw, angle, at) { | |
| 46 | yaw.sent.push({ at, angle }); | |
| 47 | while (yaw.sent.length && yaw.sent[0].at < at - SENT_MEMORY) yaw.sent.shift(); | |
| 48 | return yaw; | |
| 49 | } | |
| 50 | ||
| 51 | /** Is this angle one we sent recently? An echo, not a correction. */ | |
| 52 | const ours = (yaw, angle, at) => | |
| 53 | yaw.sent.some((s) => s.at >= at - SENT_MEMORY && Math.abs(shortWay(s.angle, angle)) <= SENT_TOLERANCE); | |
| 54 | ||
| 55 | /** | |
| 56 | * A snapshot landed. Returns the angle to look along, having decided whether | |
| 57 | * the server's word replaces ours. | |
| 58 | * | |
| 59 | * `me` is the snapshot's own body: `{ id, angle, alive }`. `at` is now, in | |
| 60 | * seconds. An empty sent log is not evidence of anything — a client that has | |
| 61 | * not spoken yet keeps what it has. | |
| 62 | */ | |
| 63 | export function settle(yaw, me, at) { | |
| 64 | const seatChanged = !yaw.ready || me.id !== yaw.id; | |
| 65 | const respawned = me.alive && !yaw.alive; | |
| 66 | const corrected = yaw.sent.length > 0 && !ours(yaw, me.angle, at); | |
| 67 | if (seatChanged || respawned || corrected) { | |
| 68 | yaw.angle = me.angle; | |
| 69 | yaw.sent.length = 0; | |
| 70 | } | |
| 71 | yaw.id = me.id; | |
| 72 | yaw.alive = me.alive; | |
| 73 | yaw.ready = true; | |
| 74 | return yaw.angle; | |
| 75 | } |