pike/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Predict through the buy phase, and hold rather than snap at a wall
Two stutters left, reported from play: 'when i hit a wall i stutter and when im in the buy phase i stutter'. Both were corrections that jumped. The buy phase turned prediction off entirely, so your own body was drawn from the newest snapshot and nothing else — a position that moves thirty times a second under a sixty-frame render, which is a stutter by construction. The reason it was off was that the pen (nowhere further than 120 from your side's spawn) is a match rule the browser did not know. It does now: the map it is already handed carries the spawn tiles, so the client reads the same rule and predicts through the phase like any other. At a wall, a lean across a corner could put the drawn body inside one, and the answer was to take the server's position outright. Pressed against a wall and strafing that fires most frames, and every one of them is a jump. It now holds the last good position instead — invisible, just as honest, and the next snapshot corrects it anyway. Anchoring is kept for the case where holding still leaves the body in a wall. 258 tests, four clean runs.
2 files
changed +53
−4
src/client.test.ts +17
| @@ -469,3 +469,20 @@ test("your own camera is never drawn as a stranger arriving", async () => { | ||
| 469 | 469 | expect(ownReturn).toBeGreaterThan(-1); |
| 470 | 470 | expect(slideCall).toBeGreaterThan(ownReturn); |
| 471 | 471 | }); |
| 472 | ||
| 473 | test("the buy phase is predicted, and its pen is the server's own rule", async () => { | |
| 474 | const bundle = await Bun.file(new URL("../static/val.js", import.meta.url)).text(); | |
| 475 | // Prediction is no longer switched off for the whole buy phase: it is on | |
| 476 | // whenever the browser knows the pen, which it learns from the map it is | |
| 477 | // already given. Drawing your own body straight from a 30Hz snapshot while | |
| 478 | // rendering at 60 is a stutter, and that is what the phase used to do. | |
| 479 | expect(bundle).toContain('snap.phase !== "buy" || pens !== null'); | |
| 480 | // And the pen is the server's number, not a guess: 120 from a spawn tile. | |
| 481 | expect(bundle).toContain("best > 120"); | |
| 482 | // A refused step holds where it was rather than snapping to the server. | |
| 483 | const predict = bundle.slice(bundle.indexOf("function predict(")); | |
| 484 | const holds = predict.indexOf("local.x = was.x"); | |
| 485 | const anchors = predict.indexOf("anchor(local, snap.me)"); | |
| 486 | expect(holds).toBeGreaterThan(-1); | |
| 487 | expect(holds).toBeLessThan(anchors); // hold first; anchor only if still stuck | |
| 488 | }); |
static/val.js +36 −4
| @@ -44,6 +44,7 @@ const HELP_SEEN = "val.help.seen"; | ||
| 44 | 44 | let map = null; |
| 45 | 45 | let solid = () => true; |
| 46 | 46 | let world = null; // what predict.js slides against: the grid, from map.json |
| 47 | let pens = null; // the spawn tiles each side is penned to during the buy phase | |
| 47 | 48 | let snap = null; |
| 48 | 49 | let socket = null; |
| 49 | 50 | let agents = null; |
| @@ -370,7 +371,17 @@ const shownAt = (p) => shown(presence, p.id, nowSec); | ||
| 370 | 371 | * phase being pulled back through the door. |
| 371 | 372 | */ |
| 372 | 373 | const predicting = () => |
| 373 | snap !== null && !watching(snap) && world !== null && snap.phase !== "buy" && snap.me.alive; | |
| 374 | snap !== null && !watching(snap) && world !== null && snap.me.alive && (snap.phase !== "buy" || pens !== null); | |
| 375 | ||
| 376 | /** The server's own rule: in the buy phase, nowhere further than 120 from a spawn. */ | |
| 377 | const outsidePen = (x, y) => { | |
| 378 | if (snap.phase !== "buy" || pens === null) return false; | |
| 379 | const cells = pens[snap.me.side] ?? []; | |
| 380 | if (cells.length === 0) return false; | |
| 381 | let best = Infinity; | |
| 382 | for (const c of cells) best = Math.min(best, Math.hypot(c.x - x, c.y - y)); | |
| 383 | return best > 120; | |
| 384 | }; | |
| 374 | 385 | |
| 375 | 386 | /** The server's answer, caught up to now, then this frame of your own keys. */ |
| 376 | 387 | function predict(dt, now) { |
| @@ -389,12 +400,20 @@ function predict(dt, now) { | ||
| 389 | 400 | // The snapshot is a moment old and the keys held since have not reached |
| 390 | 401 | // it; carry it that far forward and lean the drawn body onto it. |
| 391 | 402 | const target = project(snap.me, held.mx, held.my, speed, (now - snapAt) / 1000, world); |
| 403 | const was = { x: local.x, y: local.y }; | |
| 392 | 404 | reconcile(local, target, dt); |
| 393 | 405 | advance(local, held.mx, held.my, speed, dt, world); |
| 394 | 406 | holdBack(local, target, held.mx, held.my); |
| 395 | // A lean across a corner could put the drawn body in a wall; the server's | |
| 396 | // answer is never wrong, so take it rather than draw from inside one. | |
| 397 | if (insideWall(local, world)) anchor(local, snap.me); | |
| 407 | // A lean across a corner can put the drawn body inside a wall, and walking | |
| 408 | // out of the spawn pen is refused by the server outright. Both used to take | |
| 409 | // the server's answer, which is a jump: pressed against a wall and strafing, | |
| 410 | // that fired most frames and read as a stutter. Holding the last good place | |
| 411 | // is invisible and just as honest — the next snapshot corrects it anyway. | |
| 412 | if (insideWall(local, world) || outsidePen(local.x, local.y)) { | |
| 413 | local.x = was.x; | |
| 414 | local.y = was.y; | |
| 415 | if (insideWall(local, world)) anchor(local, snap.me); | |
| 416 | } | |
| 398 | 417 | drawnMe.id = snap.me.id; |
| 399 | 418 | drawnMe.x = local.x; |
| 400 | 419 | drawnMe.y = local.y; |
| @@ -856,6 +875,19 @@ Promise.all([fetch("/map.json").then((r) => r.json()), fetch("/agents.json").the | ||
| 856 | 875 | // The same grid the server collides against, so a guess about your own |
| 857 | 876 | // movement lands where the server's answer is about to. |
| 858 | 877 | world = { solid, tile: map.tile, r: BODY_R }; |
| 878 | // The buy phase pens everybody in their own spawn, and the map the client | |
| 879 | // is handed carries the spawn tiles ('a' attackers, 'd' defenders), so the | |
| 880 | // browser can know the rule rather than turning prediction off through the | |
| 881 | // whole phase and drawing your own body thirty times a second. | |
| 882 | const cellsOf = (letter) => { | |
| 883 | const out = []; | |
| 884 | for (let row = 0; row < rows.length; row++) | |
| 885 | for (let col = 0; col < cols; col++) | |
| 886 | if (rows[row][col] === letter) | |
| 887 | out.push({ x: (col + 0.5) * map.tile, y: (row + 0.5) * map.tile }); | |
| 888 | return out; | |
| 889 | }; | |
| 890 | pens = { atk: cellsOf("a"), def: cellsOf("d") }; | |
| 859 | 891 | $("stage").dataset.mode = mode; |
| 860 | 892 | fillHelp(); |
| 861 | 893 | resize(); |