pike/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Files 1156c415fe9c604592b0340399fb65d1c0748194
| 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 | * How long your own keys take to show in the server's answer: a send every |
| 28 | * 50ms, a hop each way, a tick. The drawn body is allowed this much of a |
| 29 | * lead over the answer without being called wrong, in seconds of running — |
| 30 | * and a disagreement inside it is leaned off gently rather than at PULL, |
| 31 | * because most of what is inside it is not a disagreement at all but the |
| 32 | * lag itself, which a hard pull turns into a yank at every start and stop. |
| 33 | */ |
| 34 | export const LEAD_SLACK = 0.08; |
| 35 | export const SOFT_PULL = 2; |
| 36 | |
| 37 | /** |
| 38 | * `anchored` counts the times the server's word was taken outright: every one |
| 39 | * is a jump. `still` is how long no key has been held, in seconds. |
| 40 | */ |
| 41 | export const makeLocal = () => ({ x: 0, y: 0, ready: false, gap: 0, anchored: 0, still: 0 }); |
| 42 | |
| 43 | /** Take the server's word for it outright. */ |
| 44 | export function anchor(local, at) { |
| 45 | local.x = at.x; |
| 46 | local.y = at.y; |
| 47 | local.ready = true; |
| 48 | local.gap = 0; |
| 49 | local.anchored += 1; |
| 50 | return local; |
| 51 | } |
| 52 | |
| 53 | /** |
| 54 | * One frame of your own movement: a direction, the speed the server will |
| 55 | * move you at, and the world to slide against — `{ solid, tile, r }`. |
| 56 | */ |
| 57 | export function advance(local, mx, my, speed, dt, world) { |
| 58 | if (!local.ready) return local; |
| 59 | const len = Math.hypot(mx, my); |
| 60 | if (len > 1) { |
| 61 | mx /= len; |
| 62 | my /= len; |
| 63 | } |
| 64 | if (speed <= 0 || (mx === 0 && my === 0) || dt <= 0) return local; |
| 65 | const to = slideCircle(local.x, local.y, mx * speed * dt, my * speed * dt, world.r, world.solid, world.tile); |
| 66 | local.x = to.x; |
| 67 | local.y = to.y; |
| 68 | return local; |
| 69 | } |
| 70 | |
| 71 | /** |
| 72 | * Where the server's last answer has got to by now. |
| 73 | * |
| 74 | * A snapshot is a position from a moment ago, and the input you have held |
| 75 | * since it was sent has not reached it yet. Rather than a sequence number |
| 76 | * the server echoes back, the unacknowledged part of your own movement is |
| 77 | * measured in time: the age of the snapshot, capped, walked through the |
| 78 | * same slide. Hold nothing and this is the server's answer exactly, which |
| 79 | * is the point — it is what the drawn body settles onto. |
| 80 | */ |
| 81 | export function project(at, mx, my, speed, age, world) { |
| 82 | const len = Math.hypot(mx, my); |
| 83 | if (len > 1) { |
| 84 | mx /= len; |
| 85 | my /= len; |
| 86 | } |
| 87 | const ahead = Math.min(LEAD_MAX, Math.max(0, age)); |
| 88 | if (speed <= 0 || ahead === 0 || (mx === 0 && my === 0)) return { x: at.x, y: at.y }; |
| 89 | return slideCircle(at.x, at.y, mx * speed * ahead, my * speed * ahead, world.r, world.solid, world.tile); |
| 90 | } |
| 91 | |
| 92 | /** |
| 93 | * Lean the drawn body toward the server's answer. A small disagreement — |
| 94 | * the width of the guess, a shot of lag — is walked off over a moment; a |
| 95 | * large one is not a disagreement but a different place, and is taken. |
| 96 | * |
| 97 | * With keys held (`mx, my`), the part of the gap along the way you are going |
| 98 | * is the lag when the drawn body is ahead: the server has not heard the keys |
| 99 | * yet. Inside `slack` that is left exactly alone — pulling it is what made |
| 100 | * every start a yank and every stop a drift. Across the way you are going, |
| 101 | * or with the server ahead, a gap is real and is leaned off at PULL. Having |
| 102 | * just stopped, a gap inside the slack is the server still catching up to |
| 103 | * where you stopped: held for the lag, then leaned off gently enough not to |
| 104 | * show. |
| 105 | */ |
| 106 | export function reconcile(local, server, dt, mx = 0, my = 0, slack = 0) { |
| 107 | if (!local.ready) return anchor(local, server); |
| 108 | const dx = server.x - local.x; |
| 109 | const dy = server.y - local.y; |
| 110 | local.gap = Math.hypot(dx, dy); |
| 111 | if (local.gap > SNAP) return anchor(local, server); |
| 112 | const hard = 1 - Math.exp(-PULL * Math.max(0, dt)); |
| 113 | const len = Math.hypot(mx, my); |
| 114 | if (len === 0) { |
| 115 | if (local.gap <= slack && local.still < LEAD_SLACK) return local; |
| 116 | const k = local.gap > slack ? hard : 1 - Math.exp(-SOFT_PULL * Math.max(0, dt)); |
| 117 | local.x += dx * k; |
| 118 | local.y += dy * k; |
| 119 | return local; |
| 120 | } |
| 121 | const ux = mx / len; |
| 122 | const uy = my / len; |
| 123 | const along = dx * ux + dy * uy; // positive: the server is ahead of the drawing |
| 124 | const acrossX = dx - along * ux; |
| 125 | const acrossY = dy - along * uy; |
| 126 | local.x += acrossX * hard; |
| 127 | local.y += acrossY * hard; |
| 128 | if (along > 0 || -along > slack) { |
| 129 | local.x += ux * along * hard; |
| 130 | local.y += uy * along * hard; |
| 131 | } |
| 132 | return local; |
| 133 | } |
| 134 | |
| 135 | /** |
| 136 | * Never further past the answer than the lag. The guess may fill the gap to |
| 137 | * where the server's word and your own held keys put you, plus `slack` — |
| 138 | * the distance your keys travel before the server has heard them — and no |
| 139 | * further: left to run, a guess that moves faster than its target is |
| 140 | * corrected finds its own equilibrium somewhere beyond it, which is a claim |
| 141 | * about a position rather than a drawing of one. |
| 142 | */ |
| 143 | export function holdBack(local, target, mx, my, slack = 0) { |
| 144 | const len = Math.hypot(mx, my); |
| 145 | if (len === 0) return local; |
| 146 | const dx = mx / len; |
| 147 | const dy = my / len; |
| 148 | const over = (local.x - target.x) * dx + (local.y - target.y) * dy - slack; |
| 149 | if (over > 0) { |
| 150 | local.x -= dx * over; |
| 151 | local.y -= dy * over; |
| 152 | } |
| 153 | return local; |
| 154 | } |
| 155 | |
| 156 | /** A body drawn inside a wall is worse than a body drawn late. */ |
| 157 | export const insideWall = (local, world) => circleBlocked(local.x, local.y, world.r, world.solid, world.tile); |
| 158 | |
| 159 | /** |
| 160 | * One frame of your own body, the whole of it: the server's answer `me` |
| 161 | * carried `age` seconds forward by the keys `held`, the drawn body leaned |
| 162 | * onto that, advanced a frame, and never drawn past it. |
| 163 | * |
| 164 | * A lean across a corner can put the drawn body inside a wall, and `refused` |
| 165 | * is the caller's word for anywhere else the server would not let you stand |
| 166 | * (the spawn pen in the buy phase). Both used to take the server's answer, |
| 167 | * which is a jump: pressed against a wall and strafing, that fired most |
| 168 | * frames and read as a stutter. Holding the last good place is invisible and |
| 169 | * just as honest — the next snapshot corrects it anyway. Anchoring is kept |
| 170 | * for the one case where holding still leaves the body in a wall. |
| 171 | */ |
| 172 | export function stepLocal(local, me, held, speed, age, dt, world, refused) { |
| 173 | const target = project(me, held.mx, held.my, speed, age, world); |
| 174 | const wasX = local.x; |
| 175 | const wasY = local.y; |
| 176 | const slack = speed * LEAD_SLACK; |
| 177 | local.still = held.mx === 0 && held.my === 0 ? local.still + dt : 0; |
| 178 | reconcile(local, target, dt, held.mx, held.my, slack); |
| 179 | advance(local, held.mx, held.my, speed, dt, world); |
| 180 | holdBack(local, target, held.mx, held.my, slack); |
| 181 | if (insideWall(local, world) || (refused !== null && refused(local.x, local.y))) { |
| 182 | local.x = wasX; |
| 183 | local.y = wasY; |
| 184 | // Holding is for a step the server would refuse, never for the server's |
| 185 | // own answer: a barrier dropped on somebody standing still leaves them |
| 186 | // inside it on the server too, and a hold that outranked that would keep |
| 187 | // the drawn body wherever it last stood, a room away, for as long as the |
| 188 | // barrier lasted. Past the snap distance, or in a wall, the answer wins. |
| 189 | if (insideWall(local, world) || Math.hypot(local.x - me.x, local.y - me.y) > SNAP) anchor(local, me); |
| 190 | } |
| 191 | return local; |
| 192 | } |