fable/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Slide along walls instead of snapping to the tile edge
moveCircle resolved each axis by snapping a blocked step to `col * TILE - r` — where `col` was the column of the *centre* of the refused position, not of the wall it hit. Walking east into the wall at x=320 from x=298 therefore put the body at 224: seventy-four units backwards, then a walk forward again, then back. A sawtooth every tick a player leans on a wall, with no input change at all. The camera smoothing did not invent the skip near walls; it was drawing one faithfully. The maths moves to src/glide.ts, which resolves a blocked step by projecting the motion onto the wall — the refused component is dropped and then creeps by halving as near the surface as a body fits, so the result is a fixed point rather than an alternation, and which component survives a corner is decided by the motion rather than by whichever axis the code resolves first. map.ts binds it to this map's tiles; the browser will bind it to map.json next, which is why it is its own module. Body radius and run speed move there with it, so there is one copy of each. Co-Authored-By: Claude Opus 5 <[email protected]>
4 files
changed +255
−49
src/glide.test.ts added +110
| @@ -0,0 +1,110 @@ | ||
| 1 | /** | |
| 2 | * Step and slide, on a grid written out here rather than the real map, so | |
| 3 | * the cases are the ones being argued about: a wall, an inner corner, and a | |
| 4 | * body already pressed against both. | |
| 5 | */ | |
| 6 | ||
| 7 | import { expect, test } from "bun:test"; | |
| 8 | import { BODY_R, circleBlocked, moveSpeed, RUN, slideCircle } from "./glide.ts"; | |
| 9 | import { moveCircle } from "./map.ts"; | |
| 10 | ||
| 11 | const TILE = 80; | |
| 12 | // A room with solid walls, open from (1,1) to (3,3). | |
| 13 | const room = ["#####", "#...#", "#...#", "#...#", "#####"]; | |
| 14 | const solid = (col: number, row: number): boolean => | |
| 15 | col < 0 || row < 0 || row >= room.length || col >= room[0]!.length || room[row]![col] === "#"; | |
| 16 | ||
| 17 | const slide = (x: number, y: number, dx: number, dy: number) => | |
| 18 | slideCircle(x, y, dx, dy, BODY_R, solid, TILE); | |
| 19 | ||
| 20 | test("an unobstructed step is the step it was asked for", () => { | |
| 21 | const to = slide(200, 200, 12, -7); | |
| 22 | expect(to.x).toBeCloseTo(212, 9); | |
| 23 | expect(to.y).toBeCloseTo(193, 9); | |
| 24 | expect(slide(200, 200, 0, 0)).toEqual({ x: 200, y: 200 }); | |
| 25 | }); | |
| 26 | ||
| 27 | test("a wall keeps the motion along it: running into it still slides", () => { | |
| 28 | // Hard against the right-hand wall (x = 320 - 16), running down and right. | |
| 29 | const to = slide(300, 160, 10, 10); | |
| 30 | expect(to.y).toBeCloseTo(170, 6); // the free axis goes through whole | |
| 31 | expect(to.x).toBeLessThanOrEqual(304); | |
| 32 | expect(to.x).toBeGreaterThan(300); // and the blocked one creeps up to the wall | |
| 33 | expect(circleBlocked(to.x, to.y, BODY_R, solid, TILE)).toBe(false); | |
| 34 | }); | |
| 35 | ||
| 36 | test("a corner is one place, whichever axis is resolved first", () => { | |
| 37 | // The bottom-right inner corner of the room: both walls at once. | |
| 38 | const diagonal = slide(280, 280, 40, 40); | |
| 39 | // Getting there one axis at a time, either order, lands in the same place. | |
| 40 | const alongX = slide(280, 280, 40, 0); | |
| 41 | const xThenY = slide(alongX.x, alongX.y, 0, 40); | |
| 42 | const alongY = slide(280, 280, 0, 40); | |
| 43 | const yThenX = slide(alongY.x, alongY.y, 40, 0); | |
| 44 | expect(xThenY.x).toBeCloseTo(diagonal.x, 3); | |
| 45 | expect(xThenY.y).toBeCloseTo(diagonal.y, 3); | |
| 46 | expect(yThenX.x).toBeCloseTo(diagonal.x, 3); | |
| 47 | expect(yThenX.y).toBeCloseTo(diagonal.y, 3); | |
| 48 | // And it is the corner: a body's radius from both walls, inside them. | |
| 49 | expect(diagonal.x).toBeGreaterThan(320 - BODY_R - 1.5); | |
| 50 | expect(diagonal.x).toBeLessThanOrEqual(320 - BODY_R); | |
| 51 | expect(diagonal.y).toBeGreaterThan(320 - BODY_R - 1.5); | |
| 52 | expect(diagonal.y).toBeLessThanOrEqual(320 - BODY_R); | |
| 53 | }); | |
| 54 | ||
| 55 | test("a body held against a wall stops moving instead of alternating", () => { | |
| 56 | // This is the skip: the old resolution snapped to the tile edge on the | |
| 57 | // blocked tick and moved freely on the next, so a player leaning on a | |
| 58 | // wall produced a position that changed every tick with no input change. | |
| 59 | let at = { x: 300, y: 200 }; | |
| 60 | const seen: number[] = []; | |
| 61 | for (let i = 0; i < 12; i++) { | |
| 62 | at = slide(at.x, at.y, 8.3, 0); | |
| 63 | seen.push(at.x); | |
| 64 | } | |
| 65 | const settled = seen.slice(4); | |
| 66 | expect(Math.max(...settled) - Math.min(...settled)).toBeLessThan(0.05); | |
| 67 | expect(Math.max(...settled)).toBeLessThanOrEqual(320 - BODY_R); | |
| 68 | }); | |
| 69 | ||
| 70 | test("a corner is a fixed point: pressed into it, nothing moves at all", () => { | |
| 71 | let at = { x: 300, y: 300 }; | |
| 72 | for (let i = 0; i < 6; i++) at = slide(at.x, at.y, 8.3, 8.3); | |
| 73 | const settled = slide(at.x, at.y, 8.3, 8.3); | |
| 74 | expect(settled.x).toBeCloseTo(at.x, 6); | |
| 75 | expect(settled.y).toBeCloseTo(at.y, 6); | |
| 76 | }); | |
| 77 | ||
| 78 | test("a step never ends inside a wall, from anywhere in the room", () => { | |
| 79 | for (let x = 96; x < 320 - 16; x += 7) | |
| 80 | for (let y = 96; y < 320 - 16; y += 7) | |
| 81 | for (const [dx, dy] of [ | |
| 82 | [30, 0], | |
| 83 | [-30, 0], | |
| 84 | [0, 30], | |
| 85 | [0, -30], | |
| 86 | [24, 24], | |
| 87 | [-24, 24], | |
| 88 | [24, -24], | |
| 89 | [-24, -24], | |
| 90 | ] as const) { | |
| 91 | const to = slide(x, y, dx, dy); | |
| 92 | expect(circleBlocked(to.x, to.y, BODY_R, solid, TILE)).toBe(false); | |
| 93 | } | |
| 94 | }); | |
| 95 | ||
| 96 | test("the real map moves by the same maths", () => { | |
| 97 | // Mid, walking east into whatever is there: the map's own binding. | |
| 98 | const to = moveCircle(1200, 640, 9, 0, BODY_R); | |
| 99 | expect(to.x).toBeGreaterThanOrEqual(1200); | |
| 100 | expect(to.x).toBeLessThanOrEqual(1209); | |
| 101 | expect(to.y).toBe(640); | |
| 102 | }); | |
| 103 | ||
| 104 | test("speed is the one table both sides read", () => { | |
| 105 | expect(moveSpeed({})).toBe(RUN); | |
| 106 | expect(moveSpeed({ haste: true })).toBeCloseTo(RUN * 1.35, 9); | |
| 107 | expect(moveSpeed({ slow: true })).toBeCloseTo(RUN * 0.55, 9); | |
| 108 | expect(moveSpeed({ heavy: true })).toBeCloseTo(RUN * 0.86, 9); | |
| 109 | expect(moveSpeed({ stopped: true, haste: true })).toBe(0); | |
| 110 | }); |
src/glide.ts added +126
| @@ -0,0 +1,126 @@ | ||
| 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 | } |
src/map.ts +8 −41
| @@ -11,6 +11,8 @@ | ||
| 11 | 11 | * and the walk distance a bot uses to path. No state. |
| 12 | 12 | */ |
| 13 | 13 | |
| 14 | import { circleBlocked, slideCircle } from "./glide.ts"; | |
| 15 | ||
| 14 | 16 | export const TILE = 80; |
| 15 | 17 | |
| 16 | 18 | // '#' wall · '.' floor · 'A'/'B' plant site · 'a' attacker spawn · 'd' defender spawn |
| @@ -94,50 +96,15 @@ export const siteCentre = (site: Site): Point => { | ||
| 94 | 96 | export const orbSpots = (): Point[] => [centreOf(4, 11), centreOf(25, 11)]; |
| 95 | 97 | |
| 96 | 98 | /** |
| 97 | * Slide a circle through the grid: each axis is moved and resolved on its | |
| 98 | * own, so running along a wall slides instead of sticking. | |
| 99 | * Slide a circle through this grid. The maths is in `glide.ts`, which the | |
| 100 | * browser runs too; here it is bound to this map's tiles. | |
| 99 | 101 | */ |
| 100 | export function moveCircle(x: number, y: number, dx: number, dy: number, r: number): Point { | |
| 101 | let nx = x; | |
| 102 | let ny = y; | |
| 103 | if (dx !== 0) { | |
| 104 | const test = nx + dx; | |
| 105 | if (!blockedCircle(test, ny, r)) nx = test; | |
| 106 | else { | |
| 107 | const col = Math.floor(test / TILE); | |
| 108 | nx = dx > 0 ? col * TILE - r - 0.01 : (col + 1) * TILE + r + 0.01; | |
| 109 | if (blockedCircle(nx, ny, r)) nx = x; | |
| 110 | } | |
| 111 | } | |
| 112 | if (dy !== 0) { | |
| 113 | const test = ny + dy; | |
| 114 | if (!blockedCircle(nx, test, r)) ny = test; | |
| 115 | else { | |
| 116 | const row = Math.floor(test / TILE); | |
| 117 | ny = dy > 0 ? row * TILE - r - 0.01 : (row + 1) * TILE + r + 0.01; | |
| 118 | if (blockedCircle(nx, ny, r)) ny = y; | |
| 119 | } | |
| 120 | } | |
| 121 | return { x: nx, y: ny }; | |
| 122 | } | |
| 102 | export const moveCircle = (x: number, y: number, dx: number, dy: number, r: number): Point => | |
| 103 | slideCircle(x, y, dx, dy, r, solidTile, TILE); | |
| 123 | 104 | |
| 124 | 105 | /** True when a circle at (x,y) overlaps any solid tile. */ |
| 125 | export function blockedCircle(x: number, y: number, r: number): boolean { | |
| 126 | const c0 = Math.floor((x - r) / TILE); | |
| 127 | const c1 = Math.floor((x + r) / TILE); | |
| 128 | const r0 = Math.floor((y - r) / TILE); | |
| 129 | const r1 = Math.floor((y + r) / TILE); | |
| 130 | for (let row = r0; row <= r1; row++) | |
| 131 | for (let col = c0; col <= c1; col++) { | |
| 132 | if (!solidTile(col, row)) continue; | |
| 133 | const nearX = Math.max(col * TILE, Math.min(x, col * TILE + TILE)); | |
| 134 | const nearY = Math.max(row * TILE, Math.min(y, row * TILE + TILE)); | |
| 135 | const dx = x - nearX; | |
| 136 | const dy = y - nearY; | |
| 137 | if (dx * dx + dy * dy < r * r) return true; | |
| 138 | } | |
| 139 | return false; | |
| 140 | } | |
| 106 | export const blockedCircle = (x: number, y: number, r: number): boolean => | |
| 107 | circleBlocked(x, y, r, solidTile, TILE); | |
| 141 | 108 | |
| 142 | 109 | /** |
| 143 | 110 | * Does a wall stand between two points? Sampled every third of a tile: a |
src/match.ts +11 −8
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | |
| 11 | 11 | import { cast, castable, agentById, buyableSlots, flashDuration, sightBlocked, SLOTS } from "./abilities.ts"; |
| 12 | 12 | import { emptyTrack } from "./aim.ts"; |
| 13 | import { BODY_R, moveSpeed, RUN } from "./glide.ts"; | |
| 13 | 14 | import { award, clampCredits, roundReward, spend, START_CREDITS } from "./economy.ts"; |
| 14 | 15 | import { |
| 15 | 16 | blockedCircle, |
| @@ -25,8 +26,10 @@ import { | ||
| 25 | 26 | import { emptyInput, type Input, type MatchState, type Player, type Side, type Slot, type Team } from "./state.ts"; |
| 26 | 27 | import { applyDamage, damage, shotSpread, SHIELD, WEAPONS, zoneFromMiss, type WeaponId } from "./weapons.ts"; |
| 27 | 28 | |
| 28 | export const PLAYER_R = 16; | |
| 29 | export const RUN_SPEED = 250; | |
| 29 | // A body's size and its speed are shared with the browser, which predicts | |
| 30 | // your own movement between snapshots: `src/glide.ts` is the one copy. | |
| 31 | export const PLAYER_R = BODY_R; | |
| 32 | export const RUN_SPEED = RUN; | |
| 30 | 33 | export const BUY_TIME = 14; |
| 31 | 34 | export const ROUND_TIME = 100; |
| 32 | 35 | export const PLANT_TIME = 4; |
| @@ -348,12 +351,12 @@ export function tick(state: MatchState, dt: number): void { | ||
| 348 | 351 | } |
| 349 | 352 | |
| 350 | 353 | function speedOf(player: Player): number { |
| 351 | let speed = RUN_SPEED; | |
| 352 | if (player.haste > 0) speed *= 1.35; | |
| 353 | if (player.slow > 0) speed *= 0.55; | |
| 354 | if (player.planting > 0 || player.defusing > 0) speed = 0; | |
| 355 | if (player.weapon === "operator") speed *= 0.86; | |
| 356 | return speed; | |
| 354 | return moveSpeed({ | |
| 355 | haste: player.haste > 0, | |
| 356 | slow: player.slow > 0, | |
| 357 | stopped: player.planting > 0 || player.defusing > 0, | |
| 358 | heavy: player.weapon === "operator", | |
| 359 | }); | |
| 357 | 360 | } |
| 358 | 361 | |
| 359 | 362 | function movePlayer(state: MatchState, player: Player, dt: number, penned: boolean): void { |