pike/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Files 03302de7b925b15e6e7d266e9a2ca7c5b4a55296
| 1 | /** |
| 2 | * How a body moves through a grid of solid tiles: the collision, the slide |
| 3 | * along a wall, and how fast it goes. |
| 4 | * |
| 5 | * One module because two programs have to agree about it. The server owns |
| 6 | * where everybody is; the browser predicts where *you* are between the |
| 7 | * snapshots it is sent, and a prediction that resolves a wall differently |
| 8 | * from the server is a camera that fights the server every tick. Written |
| 9 | * twice this drifts; written once, the guess lands on the answer. |
| 10 | * |
| 11 | * Nothing here knows about the map, the match or a player: the caller says |
| 12 | * which tiles are solid and how big one is. It is the server's `map.ts` and |
| 13 | * the browser's `map.json` asking the same question of the same maths. |
| 14 | */ |
| 15 | |
| 16 | export type Solid = (col: number, row: number) => boolean; |
| 17 | export type Point = { x: number; y: number }; |
| 18 | |
| 19 | /** The radius of a body on this map, and how fast one runs. */ |
| 20 | export const BODY_R = 16; |
| 21 | export const RUN = 250; |
| 22 | |
| 23 | /** How near a wall a refused step creeps: halvings of what was refused. */ |
| 24 | const CREEP_STEPS = 6; |
| 25 | |
| 26 | /** True when a circle at (x,y) overlaps any solid tile. */ |
| 27 | export function circleBlocked(x: number, y: number, r: number, solid: Solid, tile: number): boolean { |
| 28 | const c0 = Math.floor((x - r) / tile); |
| 29 | const c1 = Math.floor((x + r) / tile); |
| 30 | const r0 = Math.floor((y - r) / tile); |
| 31 | const r1 = Math.floor((y + r) / tile); |
| 32 | for (let row = r0; row <= r1; row++) |
| 33 | for (let col = c0; col <= c1; col++) { |
| 34 | if (!solid(col, row)) continue; |
| 35 | const nearX = Math.max(col * tile, Math.min(x, col * tile + tile)); |
| 36 | const nearY = Math.max(row * tile, Math.min(y, row * tile + tile)); |
| 37 | const dx = x - nearX; |
| 38 | const dy = y - nearY; |
| 39 | if (dx * dx + dy * dy < r * r) return true; |
| 40 | } |
| 41 | return false; |
| 42 | } |
| 43 | |
| 44 | /** The largest fraction of a refused step that is still free, by halving. */ |
| 45 | function creep(delta: number, free: (v: number) => boolean): number { |
| 46 | let lo = 0; |
| 47 | let hi = delta; |
| 48 | for (let i = 0; i < CREEP_STEPS; i++) { |
| 49 | const mid = (lo + hi) / 2; |
| 50 | if (free(mid)) lo = mid; |
| 51 | else hi = mid; |
| 52 | } |
| 53 | return lo; |
| 54 | } |
| 55 | |
| 56 | /** |
| 57 | * Move a circle by (dx,dy), sliding along whatever it runs into. |
| 58 | * |
| 59 | * The motion is projected onto the wall it meets — the blocked component is |
| 60 | * dropped, never snapped to the tile edge. A snap makes a body against a |
| 61 | * wall alternate between a snapped coordinate and a free one, which is a |
| 62 | * position that disagrees with the input every other tick; a projection is |
| 63 | * a fixed point, so a body pressed into a corner stays exactly where it is. |
| 64 | * |
| 65 | * Which component survives is decided by the motion (the longer one wins a |
| 66 | * corner it is clipping), not by an axis resolved first, so walking into a |
| 67 | * corner ends in one place whichever way the diagonal is written. |
| 68 | */ |
| 69 | export function slideCircle( |
| 70 | x: number, |
| 71 | y: number, |
| 72 | dx: number, |
| 73 | dy: number, |
| 74 | r: number, |
| 75 | solid: Solid, |
| 76 | tile: number, |
| 77 | ): Point { |
| 78 | if (dx === 0 && dy === 0) return { x, y }; |
| 79 | const free = (px: number, py: number): boolean => !circleBlocked(px, py, r, solid, tile); |
| 80 | if (free(x + dx, y + dy)) return { x: x + dx, y: y + dy }; |
| 81 | |
| 82 | const okX = dx !== 0 && free(x + dx, y); |
| 83 | const okY = dy !== 0 && free(x, y + dy); |
| 84 | const longerIsX = Math.abs(dx) >= Math.abs(dy); |
| 85 | let ax = okX ? dx : 0; |
| 86 | let ay = okY ? dy : 0; |
| 87 | // Both axes open but the diagonal shut is a corner being clipped: the |
| 88 | // longer component goes through whole and the shorter one creeps. |
| 89 | if (okX && okY) { |
| 90 | if (longerIsX) ay = 0; |
| 91 | else ax = 0; |
| 92 | } |
| 93 | |
| 94 | let nx = x + ax; |
| 95 | let ny = y + ay; |
| 96 | // Whatever was refused still creeps as near the wall as it fits, rather |
| 97 | // than stopping a whole step short of it — a step is a third of a body. |
| 98 | const creepX = (): void => { |
| 99 | if (ax === 0 && dx !== 0) nx = x + creep(dx, (v) => free(x + v, ny)); |
| 100 | }; |
| 101 | const creepY = (): void => { |
| 102 | if (ay === 0 && dy !== 0) ny = y + creep(dy, (v) => free(nx, y + v)); |
| 103 | }; |
| 104 | if (longerIsX) { |
| 105 | creepX(); |
| 106 | creepY(); |
| 107 | } else { |
| 108 | creepY(); |
| 109 | creepX(); |
| 110 | } |
| 111 | return { x: nx, y: ny }; |
| 112 | } |
| 113 | |
| 114 | /** |
| 115 | * How fast a body moves, given what is on it. The browser reads the same |
| 116 | * four facts out of its snapshot that the match reads off a player, so a |
| 117 | * prediction runs at the speed the server is about to use. |
| 118 | */ |
| 119 | export function moveSpeed(state: { haste?: boolean; slow?: boolean; stopped?: boolean; heavy?: boolean }): number { |
| 120 | if (state.stopped) return 0; |
| 121 | let speed = RUN; |
| 122 | if (state.haste) speed *= 1.35; |
| 123 | if (state.slow) speed *= 0.55; |
| 124 | if (state.heavy) speed *= 0.86; |
| 125 | return speed; |
| 126 | } |