/** * How a body moves through a grid of solid tiles: the collision, the slide * along a wall, and how fast it goes. * * One module because two programs have to agree about it. The server owns * where everybody is; the browser predicts where *you* are between the * snapshots it is sent, and a prediction that resolves a wall differently * from the server is a camera that fights the server every tick. Written * twice this drifts; written once, the guess lands on the answer. * * Nothing here knows about the map, the match or a player: the caller says * which tiles are solid and how big one is. It is the server's `map.ts` and * the browser's `map.json` asking the same question of the same maths. */ export type Solid = (col: number, row: number) => boolean; export type Point = { x: number; y: number }; /** The radius of a body on this map, and how fast one runs. */ export const BODY_R = 16; export const RUN = 250; /** How near a wall a refused step creeps: halvings of what was refused. */ const CREEP_STEPS = 6; /** True when a circle at (x,y) overlaps any solid tile. */ export function circleBlocked(x: number, y: number, r: number, solid: Solid, tile: number): boolean { const c0 = Math.floor((x - r) / tile); const c1 = Math.floor((x + r) / tile); const r0 = Math.floor((y - r) / tile); const r1 = Math.floor((y + r) / tile); for (let row = r0; row <= r1; row++) for (let col = c0; col <= c1; col++) { if (!solid(col, row)) continue; const nearX = Math.max(col * tile, Math.min(x, col * tile + tile)); const nearY = Math.max(row * tile, Math.min(y, row * tile + tile)); const dx = x - nearX; const dy = y - nearY; if (dx * dx + dy * dy < r * r) return true; } return false; } /** The largest fraction of a refused step that is still free, by halving. */ function creep(delta: number, free: (v: number) => boolean): number { let lo = 0; let hi = delta; for (let i = 0; i < CREEP_STEPS; i++) { const mid = (lo + hi) / 2; if (free(mid)) lo = mid; else hi = mid; } return lo; } /** * Move a circle by (dx,dy), sliding along whatever it runs into. * * The motion is projected onto the wall it meets — the blocked component is * dropped, never snapped to the tile edge. A snap makes a body against a * wall alternate between a snapped coordinate and a free one, which is a * position that disagrees with the input every other tick; a projection is * a fixed point, so a body pressed into a corner stays exactly where it is. * * Which component survives is decided by the motion (the longer one wins a * corner it is clipping), not by an axis resolved first, so walking into a * corner ends in one place whichever way the diagonal is written. */ export function slideCircle( x: number, y: number, dx: number, dy: number, r: number, solid: Solid, tile: number, ): Point { if (dx === 0 && dy === 0) return { x, y }; const free = (px: number, py: number): boolean => !circleBlocked(px, py, r, solid, tile); if (free(x + dx, y + dy)) return { x: x + dx, y: y + dy }; const okX = dx !== 0 && free(x + dx, y); const okY = dy !== 0 && free(x, y + dy); const longerIsX = Math.abs(dx) >= Math.abs(dy); let ax = okX ? dx : 0; let ay = okY ? dy : 0; // Both axes open but the diagonal shut is a corner being clipped: the // longer component goes through whole and the shorter one creeps. if (okX && okY) { if (longerIsX) ay = 0; else ax = 0; } let nx = x + ax; let ny = y + ay; // Whatever was refused still creeps as near the wall as it fits, rather // than stopping a whole step short of it — a step is a third of a body. const creepX = (): void => { if (ax === 0 && dx !== 0) nx = x + creep(dx, (v) => free(x + v, ny)); }; const creepY = (): void => { if (ay === 0 && dy !== 0) ny = y + creep(dy, (v) => free(nx, y + v)); }; if (longerIsX) { creepX(); creepY(); } else { creepY(); creepX(); } return { x: nx, y: ny }; } /** * How fast a body moves, given what is on it. The browser reads the same * four facts out of its snapshot that the match reads off a player, so a * prediction runs at the speed the server is about to use. */ export function moveSpeed(state: { haste?: boolean; slow?: boolean; stopped?: boolean; heavy?: boolean }): number { if (state.stopped) return 0; let speed = RUN; if (state.haste) speed *= 1.35; if (state.slow) speed *= 0.55; if (state.heavy) speed *= 0.86; return speed; }