pike/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]>
Wren committed d9bf83f Browse files
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 @@
1111 * and the walk distance a bot uses to path. No state.
1212 */
1313
14 import { circleBlocked, slideCircle } from "./glide.ts";
15
1416 export const TILE = 80;
1517
1618 // '#' wall · '.' floor · 'A'/'B' plant site · 'a' attacker spawn · 'd' defender spawn
@@ -94,50 +96,15 @@ export const siteCentre = (site: Site): Point => {
9496 export const orbSpots = (): Point[] => [centreOf(4, 11), centreOf(25, 11)];
9597
9698 /**
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.
99101 */
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);
123104
124105 /** 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);
141108
142109 /**
143110 * Does a wall stand between two points? Sampled every third of a tile: a
src/match.ts +11 −8
@@ -10,6 +10,7 @@
1010
1111 import { cast, castable, agentById, buyableSlots, flashDuration, sightBlocked, SLOTS } from "./abilities.ts";
1212 import { emptyTrack } from "./aim.ts";
13 import { BODY_R, moveSpeed, RUN } from "./glide.ts";
1314 import { award, clampCredits, roundReward, spend, START_CREDITS } from "./economy.ts";
1415 import {
1516 blockedCircle,
@@ -25,8 +26,10 @@ import {
2526 import { emptyInput, type Input, type MatchState, type Player, type Side, type Slot, type Team } from "./state.ts";
2627 import { applyDamage, damage, shotSpread, SHIELD, WEAPONS, zoneFromMiss, type WeaponId } from "./weapons.ts";
2728
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;
3033 export const BUY_TIME = 14;
3134 export const ROUND_TIME = 100;
3235 export const PLANT_TIME = 4;
@@ -348,12 +351,12 @@ export function tick(state: MatchState, dt: number): void {
348351 }
349352
350353 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 });
357360 }
358361
359362 function movePlayer(state: MatchState, player: Player, dt: number, penned: boolean): void {
↑↓ navigate ↵ open esc close

Keyboard shortcuts