fable/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]>
Wren committed 67bc3f0 Browse files
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";
2020 import { AGENT_COLOUR, drawScene, makeScene } from "./scene.js";
2121 import { moveVector } from "./steer.js";
2222 import { drawTopdown } from "./topdown.js";
23 import { aim as aimAt, makeYaw, settle, told, turn } from "./yaw.js";
2324
2425 const $ = (id) => document.getElementById(id);
2526 const view = $("view");
@@ -38,7 +39,10 @@ let socket = null;
3839 let agents = null;
3940 const shown = new Map(); // smoothed positions, so 15 frames a second look like 60
4041 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();
4246 let locked = false;
4347 let firing = false;
4448 let mode = COARSE ? "top" : "fp"; // a raycaster with touch look is miserable
@@ -76,10 +80,11 @@ function connect() {
7680 socket.onmessage = (event) => {
7781 const data = JSON.parse(event.data);
7882 if (data.t !== "s") return;
79 const first = snap === null;
8083 snap = data;
8184 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);
8388 gunSnapshot(gun, data);
8489 paintHud(data);
8590 maybeHelp(data);
@@ -96,7 +101,8 @@ function setMode(next) {
96101 mode = next;
97102 $("stage").dataset.mode = next;
98103 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.
100106 }
101107
102108 addEventListener("keydown", (event) => {
@@ -157,7 +163,7 @@ document.addEventListener("pointerlockchange", () => {
157163 $("stage").dataset.locked = locked ? "1" : "";
158164 });
159165 addEventListener("mousemove", (event) => {
160 if (locked) yaw += event.movementX * LOOK;
166 if (locked) turn(head, event.movementX * LOOK);
161167 });
162168 addEventListener("mousedown", (event) => {
163169 if (locked && event.button === 0) firing = true;
@@ -236,15 +242,17 @@ function readIntent() {
236242 intent.mx = ax;
237243 intent.my = ay;
238244 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);
241247 intent.mx = walk.x;
242248 intent.my = walk.y;
243249 } else {
244250 const aim = aimTouch || pointer;
245251 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;
248256 }
249257 intent.fire = firing || keys.has("Space");
250258 intent.use = keys.has("KeyF");
@@ -258,6 +266,8 @@ setInterval(() => {
258266 if (!snap || !map) return;
259267 const now = readIntent();
260268 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);
261271 }, 50);
262272
263273 // ------------------------------------------------------------------ drawing
@@ -328,7 +338,7 @@ function predict(dt, now) {
328338 drawnMe.id = snap.me.id;
329339 drawnMe.x = local.x;
330340 drawnMe.y = local.y;
331 drawnMe.angle = mode === "fp" ? yaw : snap.me.angle;
341 drawnMe.angle = mode === "fp" ? head.angle : snap.me.angle;
332342 }
333343
334344 function frame(now) {
@@ -345,12 +355,12 @@ function frame(now) {
345355 }
346356 // Turning with the arrow keys belongs to the frame, at a rate a second,
347357 // 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);
350360 predict(dt, now);
351361 beeper.update(snap.spike.planted, snap.spike.fuse, now / 1000);
352362 const eye = look(snap.me);
353 const facing = mode === "fp" ? yaw : eye.angle;
363 const facing = mode === "fp" ? head.angle : eye.angle;
354364
355365 if (mode === "fp" && !mapOpen) {
356366 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 }
↑↓ navigate ↵ open esc close

Keyboard shortcuts