rook/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Merge pull request #9 from wren/8-bots-that-never-miss-and-walls-that-make
Bots that never miss, and walls that make the view skip
14 files
changed +1060
−82
README.md +14 −1
| @@ -37,7 +37,13 @@ server: | ||
| 37 | 37 | `src/arena.ts` by comparing the players either side of a tick — it carries |
| 38 | 38 | names, the gun and an age, and never a position. |
| 39 | 39 | - **Bots** fill every empty seat and use their kit: smokes on the way in, |
| 40 | flashes before the entry, a recon dart, a molly on the planted spike. | |
| 40 | flashes before the entry, a recon dart, a molly on the planted spike. They | |
| 41 | shoot through the same spread everyone does, and they aim like something | |
| 42 | with hands: 200–350ms to react to a target they have just seen, an aim | |
| 43 | cone that opens several degrees on the swing and only settles while they | |
| 44 | hold the same target, and more of both when you are crossing their view | |
| 45 | (`src/aim.ts`). Every roll of it comes from the match's seeded random, so | |
| 46 | a seed replays the same round. | |
| 41 | 47 | |
| 42 | 48 | Agents: Ember, Veil, Sable, Mender, Rook — two basics bought with credits |
| 43 | 49 | (charges last the round), a free signature on a cooldown, and an ultimate |
| @@ -47,6 +53,13 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator. | ||
| 47 | 53 | |
| 48 | 54 | ## What is not |
| 49 | 55 | |
| 56 | - Your own body is *drawn* from a guess — the browser runs your own movement | |
| 57 | forward from your own keys between snapshots, through the same | |
| 58 | step-and-slide the server uses (`src/glide.ts`), and carries the last | |
| 59 | snapshot forward by up to a quarter second of held input. It is a drawing | |
| 60 | optimisation only: nothing is sent, every snapshot leans the drawn body | |
| 61 | back onto the server's answer, and a real disagreement is taken outright. | |
| 62 | Everybody else is still smoothed toward the last position the server sent. | |
| 50 | 63 | - The view is a raycaster: walls are one tile tall and flat-shaded, there is |
| 51 | 64 | no looking up or down, and everything that is not a wall — bodies, the |
| 52 | 65 | spike, ability zones — is a flat billboard scaled by distance and clipped |
src/aim.test.ts added +230
| @@ -0,0 +1,230 @@ | ||
| 1 | /** | |
| 2 | * How a bot points a gun, tested as the pure maths it is, and then once | |
| 3 | * through a whole bot to prove the gate is actually wired to the trigger. | |
| 4 | */ | |
| 5 | ||
| 6 | import { expect, test } from "bun:test"; | |
| 7 | import { | |
| 8 | acquire, | |
| 9 | aimCone, | |
| 10 | canFire, | |
| 11 | CONE_FLOOR, | |
| 12 | crossRate, | |
| 13 | drift, | |
| 14 | emptyTrack, | |
| 15 | FORGET, | |
| 16 | heldFor, | |
| 17 | lose, | |
| 18 | REACTION_MAX, | |
| 19 | REACTION_MIN, | |
| 20 | reactionTime, | |
| 21 | } from "./aim.ts"; | |
| 22 | import { botThink } from "./bots.ts"; | |
| 23 | import { newMatch, RUN_SPEED } from "./match.ts"; | |
| 24 | import type { MatchState, Player } from "./state.ts"; | |
| 25 | ||
| 26 | const rolls = (...values: number[]) => { | |
| 27 | let at = 0; | |
| 28 | return () => values[at++ % values.length]!; | |
| 29 | }; | |
| 30 | ||
| 31 | test("the reaction a bot draws is between 200 and 350ms, whatever the roll", () => { | |
| 32 | expect(reactionTime(0)).toBeCloseTo(REACTION_MIN, 9); | |
| 33 | expect(reactionTime(1)).toBeCloseTo(REACTION_MAX, 9); | |
| 34 | expect(reactionTime(0.5)).toBeCloseTo((REACTION_MIN + REACTION_MAX) / 2, 9); | |
| 35 | // A roll out of range is still a reaction, not a bot that never shoots. | |
| 36 | expect(reactionTime(-3)).toBe(REACTION_MIN); | |
| 37 | expect(reactionTime(9)).toBe(REACTION_MAX); | |
| 38 | }); | |
| 39 | ||
| 40 | test("the trigger gate is a pure function of when acquisition happened", () => { | |
| 41 | const track = emptyTrack(); | |
| 42 | // Nothing in sight is never a shot, however long the round has run. | |
| 43 | expect(canFire(track, 100)).toBe(false); | |
| 44 | ||
| 45 | acquire(track, "bot-red-1", 10, { x: 0, y: 0 }, rolls(0, 0.5)); | |
| 46 | expect(track.reaction).toBeCloseTo(REACTION_MIN, 9); | |
| 47 | expect(canFire(track, 10)).toBe(false); | |
| 48 | expect(canFire(track, 10.19)).toBe(false); | |
| 49 | expect(canFire(track, 10.21)).toBe(true); | |
| 50 | expect(canFire(track, 12)).toBe(true); | |
| 51 | expect(heldFor(track, 12)).toBeCloseTo(2, 9); | |
| 52 | }); | |
| 53 | ||
| 54 | test("a new target starts the reaction again; the same one does not", () => { | |
| 55 | const track = emptyTrack(); | |
| 56 | acquire(track, "one", 5, { x: 100, y: 100 }, rolls(1, 0.5)); | |
| 57 | expect(track.since).toBe(5); | |
| 58 | acquire(track, "one", 5.3, { x: 180, y: 100 }, rolls(0, 0.5)); | |
| 59 | expect(track.since).toBe(5); // the hold carries on | |
| 60 | expect(canFire(track, 5.4)).toBe(true); | |
| 61 | acquire(track, "two", 5.4, { x: 0, y: 0 }, rolls(0, 0.5)); | |
| 62 | expect(track.since).toBe(5.4); // swinging onto somebody else costs again | |
| 63 | expect(canFire(track, 5.5)).toBe(false); | |
| 64 | }); | |
| 65 | ||
| 66 | test("a hold survives a blink out of sight, and no longer", () => { | |
| 67 | const track = emptyTrack(); | |
| 68 | acquire(track, "one", 1, { x: 0, y: 0 }, rolls(0, 0.5)); | |
| 69 | lose(track, 1 + FORGET / 2); | |
| 70 | expect(track.id).toBe("one"); | |
| 71 | // Back a moment later: the same hold, already through its reaction. | |
| 72 | acquire(track, "one", 1 + FORGET / 2, { x: 0, y: 0 }, rolls(0, 0.5)); | |
| 73 | expect(track.since).toBe(1); | |
| 74 | // Gone for longer than that and it is a fresh acquisition. | |
| 75 | lose(track, 3); | |
| 76 | expect(track.id).toBe(null); | |
| 77 | acquire(track, "one", 3, { x: 0, y: 0 }, rolls(0, 0.5)); | |
| 78 | expect(track.since).toBe(3); | |
| 79 | expect(canFire(track, 3.1)).toBe(false); | |
| 80 | }); | |
| 81 | ||
| 82 | test("the cone starts wide, settles the longer one target is held, and never closes", () => { | |
| 83 | const swing = aimCone(0, 0); | |
| 84 | const second = aimCone(1, 0); | |
| 85 | const settled = aimCone(3, 0); | |
| 86 | expect(swing).toBeGreaterThan(0.08); // several degrees on the swing | |
| 87 | expect(second).toBeLessThan(swing / 2); | |
| 88 | expect(settled).toBeLessThan(second); | |
| 89 | expect(settled).toBeGreaterThanOrEqual(CONE_FLOOR); | |
| 90 | expect(aimCone(99, 0)).toBeCloseTo(CONE_FLOOR, 6); | |
| 91 | }); | |
| 92 | ||
| 93 | test("a target crossing the view costs the bot accuracy", () => { | |
| 94 | expect(aimCone(3, 1)).toBeGreaterThan(aimCone(3, 0) * 3); | |
| 95 | expect(aimCone(3, 0.5)).toBeGreaterThan(aimCone(3, 0)); | |
| 96 | expect(aimCone(3, 0.5)).toBeLessThan(aimCone(3, 1)); | |
| 97 | // Nonsense from the caller is clamped, not multiplied out. | |
| 98 | expect(aimCone(3, 50)).toBeCloseTo(aimCone(3, 1), 9); | |
| 99 | }); | |
| 100 | ||
| 101 | test("crossing is measured across the sight line, not along it", () => { | |
| 102 | const bot = { x: 0, y: 0 }; | |
| 103 | const walking = emptyTrack(); | |
| 104 | acquire(walking, "t", 0, { x: 300, y: 0 }, rolls(0, 0.5)); | |
| 105 | // Straight down the barrel: nothing to track. | |
| 106 | expect(crossRate(walking, bot, { x: 260, y: 0 }, 0.1)).toBeCloseTo(0, 6); | |
| 107 | const strafing = emptyTrack(); | |
| 108 | acquire(strafing, "t", 0, { x: 300, y: 0 }, rolls(0, 0.5)); | |
| 109 | // Straight across it at a run: all of it but the turn of the sight line. | |
| 110 | expect(crossRate(strafing, bot, { x: 300, y: 25 }, 0.1)).toBeCloseTo(250, -1); | |
| 111 | // And it remembers where the target went, so the next tick measures again. | |
| 112 | expect(crossRate(strafing, bot, { x: 300, y: 25 }, 0.1)).toBeCloseTo(0, 6); | |
| 113 | }); | |
| 114 | ||
| 115 | test("the error wanders inside the cone instead of being redrawn every tick", () => { | |
| 116 | const track = emptyTrack(); | |
| 117 | acquire(track, "one", 0, { x: 0, y: 0 }, rolls(0, 1)); | |
| 118 | // Drawn at the edge of the swing cone on acquisition. | |
| 119 | expect(Math.abs(track.error)).toBeCloseTo(aimCone(0, 0), 9); | |
| 120 | // One tick moves it a little, not to somewhere unrelated. | |
| 121 | const before = track.error; | |
| 122 | const after = drift(track, aimCone(0, 0), 1 / 30, rolls(0.9)); | |
| 123 | expect(Math.abs(after - before)).toBeLessThan(aimCone(0, 0) / 4); | |
| 124 | // And it is squeezed toward the barrel as the cone closes around it. | |
| 125 | for (let i = 0; i < 90; i++) drift(track, aimCone(3, 0), 1 / 30, rolls(0.5, 1, 0)); | |
| 126 | expect(Math.abs(track.error)).toBeLessThanOrEqual(aimCone(3, 0) + 1e-12); | |
| 127 | }); | |
| 128 | ||
| 129 | // ------------------------------------------------------------- through a bot | |
| 130 | ||
| 131 | const facing = (state: MatchState): { bot: Player; prey: Player } => { | |
| 132 | const bot = state.players.find((p) => p.side === "def")!; | |
| 133 | const prey = state.players.find((p) => p.side === "atk")!; | |
| 134 | // Nose to nose in the open, with nothing between them. | |
| 135 | bot.x = 1200; | |
| 136 | bot.y = 640; | |
| 137 | prey.x = 1200 + 200; | |
| 138 | prey.y = 640; | |
| 139 | bot.angle = 0; | |
| 140 | bot.input.angle = 0; | |
| 141 | bot.weapon = "vandal"; | |
| 142 | bot.ammo = 25; | |
| 143 | return { bot, prey }; | |
| 144 | }; | |
| 145 | ||
| 146 | const live = (): MatchState => { | |
| 147 | const state = newMatch(4242); | |
| 148 | state.phase = "live"; | |
| 149 | state.clock = 100; | |
| 150 | return state; | |
| 151 | }; | |
| 152 | ||
| 153 | test("a bot that has just seen somebody does not shoot for a reaction", () => { | |
| 154 | const state = live(); | |
| 155 | const { bot } = facing(state); | |
| 156 | let firstFire = -1; | |
| 157 | for (let i = 0; i < 30 && firstFire < 0; i++) { | |
| 158 | botThink(state, bot, 1 / 30); | |
| 159 | state.now += 1 / 30; | |
| 160 | if (bot.input.fire) firstFire = i / 30; | |
| 161 | } | |
| 162 | expect(firstFire).toBeGreaterThanOrEqual(REACTION_MIN); | |
| 163 | expect(firstFire).toBeLessThanOrEqual(REACTION_MAX + 0.15); | |
| 164 | }); | |
| 165 | ||
| 166 | test("a bot that has been tracking you is far tighter than one that just swung on", () => { | |
| 167 | const state = live(); | |
| 168 | const { bot, prey } = facing(state); | |
| 169 | const truth = () => Math.atan2(prey.y - bot.y, prey.x - bot.x); | |
| 170 | const spread: number[] = []; | |
| 171 | for (let i = 0; i < 120; i++) { | |
| 172 | botThink(state, bot, 1 / 30); | |
| 173 | state.now += 1 / 30; | |
| 174 | bot.angle = bot.input.angle; // the match loop's job, done by hand | |
| 175 | spread.push(Math.abs(bot.angle - truth())); | |
| 176 | } | |
| 177 | const swing = Math.max(...spread.slice(0, 15)); | |
| 178 | const held = Math.max(...spread.slice(80)); | |
| 179 | expect(swing).toBeGreaterThan(held * 2); | |
| 180 | expect(held).toBeLessThan(0.03); | |
| 181 | }); | |
| 182 | ||
| 183 | test("a strafing target keeps a settled bot honest", () => { | |
| 184 | const still = live(); | |
| 185 | const a = facing(still); | |
| 186 | const strafing = live(); | |
| 187 | const b = facing(strafing); | |
| 188 | let stillWorst = 0; | |
| 189 | let strafeWorst = 0; | |
| 190 | for (let i = 0; i < 180; i++) { | |
| 191 | botThink(still, a.bot, 1 / 30); | |
| 192 | botThink(strafing, b.bot, 1 / 30); | |
| 193 | still.now += 1 / 30; | |
| 194 | strafing.now += 1 / 30; | |
| 195 | a.bot.angle = a.bot.input.angle; | |
| 196 | b.bot.angle = b.bot.input.angle; | |
| 197 | // One stands in the open; the other runs across the sight line. | |
| 198 | b.prey.y = 640 + Math.sin(i / 8) * 90; | |
| 199 | if (i > 60) { | |
| 200 | stillWorst = Math.max(stillWorst, Math.abs(a.bot.angle - Math.atan2(a.prey.y - a.bot.y, a.prey.x - a.bot.x))); | |
| 201 | strafeWorst = Math.max(strafeWorst, Math.abs(b.bot.angle - Math.atan2(b.prey.y - b.bot.y, b.prey.x - b.bot.x))); | |
| 202 | } | |
| 203 | } | |
| 204 | expect(strafeWorst).toBeGreaterThan(stillWorst); | |
| 205 | }); | |
| 206 | ||
| 207 | test("the aim is still deterministic in the state: one seed, one round", () => { | |
| 208 | const play = (): number[] => { | |
| 209 | const state = live(); | |
| 210 | const { bot, prey } = facing(state); | |
| 211 | const out: number[] = []; | |
| 212 | for (let i = 0; i < 60; i++) { | |
| 213 | botThink(state, bot, 1 / 30); | |
| 214 | state.now += 1 / 30; | |
| 215 | bot.angle = bot.input.angle; | |
| 216 | prey.x = 1400 + i; | |
| 217 | out.push(bot.angle, bot.input.fire ? 1 : 0); | |
| 218 | } | |
| 219 | return out; | |
| 220 | }; | |
| 221 | expect(play()).toEqual(play()); | |
| 222 | }); | |
| 223 | ||
| 224 | test("a full run of speed is what the crossing term is measured against", () => { | |
| 225 | expect(RUN_SPEED).toBeGreaterThan(0); | |
| 226 | const track = emptyTrack(); | |
| 227 | acquire(track, "t", 0, { x: 400, y: 0 }, rolls(0, 0.5)); | |
| 228 | const rate = crossRate(track, { x: 0, y: 0 }, { x: 400, y: RUN_SPEED / 30 }, 1 / 30); | |
| 229 | expect(rate / RUN_SPEED).toBeCloseTo(1, 3); | |
| 230 | }); |
src/aim.ts added +128
| @@ -0,0 +1,128 @@ | ||
| 1 | /** | |
| 2 | * How a bot points a gun. | |
| 3 | * | |
| 4 | * The shot itself is shared with people — weapon spread, the move penalty, | |
| 5 | * the spray, the blind wobble all live in `weapons.ts` and apply to everyone | |
| 6 | * the same way. What lives here is the part a person is bad at and a bot was | |
| 7 | * not: seeing somebody, and getting the barrel onto them. | |
| 8 | * | |
| 9 | * A hold on a target has three honest costs. It takes a moment to notice | |
| 10 | * (`reaction`), the barrel starts somewhere wrong and only settles the longer | |
| 11 | * the same target is held (`aimCone`), and a target crossing the view is | |
| 12 | * harder to stay on than one walking down the barrel (`crossRate`). | |
| 13 | * | |
| 14 | * Everything here is a pure function of a track and a roll, so the caller | |
| 15 | * supplies the randomness — the match's seeded `random`, so a replay of a | |
| 16 | * seed still plays the same round. | |
| 17 | */ | |
| 18 | ||
| 19 | export type Point = { x: number; y: number }; | |
| 20 | ||
| 21 | /** One bot's hold on one target. Scratch space, carried on the player. */ | |
| 22 | export type Track = { | |
| 23 | /** The target being held, or null when nothing is in sight. */ | |
| 24 | id: string | null; | |
| 25 | /** Match time the hold started: every cost here is measured from it. */ | |
| 26 | since: number; | |
| 27 | /** Match time the target was last actually seen. */ | |
| 28 | seen: number; | |
| 29 | /** Seconds between seeing this target and being allowed to pull. */ | |
| 30 | reaction: number; | |
| 31 | /** How wrong the barrel is, in radians, signed. */ | |
| 32 | error: number; | |
| 33 | /** Where the target was last seen, to measure how it is moving. */ | |
| 34 | x: number; | |
| 35 | y: number; | |
| 36 | }; | |
| 37 | ||
| 38 | export const emptyTrack = (): Track => ({ id: null, since: 0, seen: 0, reaction: 0, error: 0, x: 0, y: 0 }); | |
| 39 | ||
| 40 | /** A bot notices somebody somewhere in here, never sooner. */ | |
| 41 | export const REACTION_MIN = 0.2; | |
| 42 | export const REACTION_MAX = 0.35; | |
| 43 | /** A hold survives a blink — a smoke edge, a corner — without re-reacting. */ | |
| 44 | export const FORGET = 0.4; | |
| 45 | /** Seconds for the cone to fall by 1/e while the same target is held. */ | |
| 46 | export const SETTLE = 0.8; | |
| 47 | /** The cone a bot settles to: under a Vandal's own spread, never zero. */ | |
| 48 | export const CONE_FLOOR = 0.008; | |
| 49 | /** What the cone opens to the instant a target is acquired (~6°). */ | |
| 50 | export const CONE_SWING = 0.105; | |
| 51 | /** What a target crossing the view at a full run adds to it (~3.4°). */ | |
| 52 | export const CONE_MOVE = 0.06; | |
| 53 | /** How fast the error wanders inside the cone, in cone-widths a second. */ | |
| 54 | export const DRIFT = 2.4; | |
| 55 | ||
| 56 | const clamp01 = (n: number): number => (n < 0 ? 0 : n > 1 ? 1 : n); | |
| 57 | ||
| 58 | /** The reaction a bot draws on acquiring a target: 200–350ms. */ | |
| 59 | export const reactionTime = (roll: number): number => | |
| 60 | REACTION_MIN + (REACTION_MAX - REACTION_MIN) * clamp01(roll); | |
| 61 | ||
| 62 | /** Seconds this target has been held. */ | |
| 63 | export const heldFor = (track: Track, now: number): number => Math.max(0, now - track.since); | |
| 64 | ||
| 65 | /** | |
| 66 | * The trigger gate, as a pure function of when acquisition happened: no | |
| 67 | * target, or a target seen less than a reaction ago, is no shot. | |
| 68 | */ | |
| 69 | export const canFire = (track: Track, now: number): boolean => | |
| 70 | track.id !== null && now - track.since >= track.reaction; | |
| 71 | ||
| 72 | /** | |
| 73 | * The half-angle the barrel may be wrong by: wide on the swing, settling | |
| 74 | * toward the floor the longer one target is held, and wider again for a | |
| 75 | * target crossing the view (`crossing` is 0 for still, 1 for a full run). | |
| 76 | */ | |
| 77 | export function aimCone(held: number, crossing: number): number { | |
| 78 | return CONE_FLOOR + CONE_SWING * Math.exp(-Math.max(0, held) / SETTLE) + CONE_MOVE * clamp01(crossing); | |
| 79 | } | |
| 80 | ||
| 81 | /** | |
| 82 | * Point the track at a target. A different target — or the same one after | |
| 83 | * the hold was forgotten — starts a fresh hold: a new reaction to serve and | |
| 84 | * an error drawn anywhere in the swing cone. | |
| 85 | */ | |
| 86 | export function acquire(track: Track, id: string, now: number, at: Point, roll: () => number): void { | |
| 87 | if (track.id !== id || now - track.seen > FORGET || now < track.since) { | |
| 88 | track.id = id; | |
| 89 | track.since = now; | |
| 90 | track.reaction = reactionTime(roll()); | |
| 91 | track.error = (roll() - 0.5) * 2 * aimCone(0, 0); | |
| 92 | track.x = at.x; | |
| 93 | track.y = at.y; | |
| 94 | } | |
| 95 | track.seen = now; | |
| 96 | } | |
| 97 | ||
| 98 | /** Nothing in sight. The hold survives a blink, then it is gone. */ | |
| 99 | export function lose(track: Track, now: number): void { | |
| 100 | if (track.id !== null && now - track.seen > FORGET) track.id = null; | |
| 101 | } | |
| 102 | ||
| 103 | /** | |
| 104 | * How fast the target is crossing the bot's line of sight, in units a | |
| 105 | * second. Walking down the barrel is easy to stay on; strafing is not, and | |
| 106 | * it is the part `atan2` to an exact position used to give away for free. | |
| 107 | */ | |
| 108 | export function crossRate(track: Track, bot: Point, at: Point, dt: number): number { | |
| 109 | const moved = { x: at.x - track.x, y: at.y - track.y }; | |
| 110 | track.x = at.x; | |
| 111 | track.y = at.y; | |
| 112 | if (dt <= 0) return 0; | |
| 113 | const ax = at.x - bot.x; | |
| 114 | const ay = at.y - bot.y; | |
| 115 | const len = Math.hypot(ax, ay) || 1; | |
| 116 | return Math.abs(moved.x * (-ay / len) + moved.y * (ax / len)) / dt; | |
| 117 | } | |
| 118 | ||
| 119 | /** | |
| 120 | * Wander the error inside the cone and hand it back. It is a held mistake, | |
| 121 | * not fresh noise every tick: noise resampled at 30Hz averages to a perfect | |
| 122 | * barrel over a burst, which is the thing that felt like an aimbot. | |
| 123 | */ | |
| 124 | export function drift(track: Track, cone: number, dt: number, roll: () => number): number { | |
| 125 | track.error += (roll() - 0.5) * 2 * cone * DRIFT * dt; | |
| 126 | track.error = Math.max(-cone, Math.min(cone, track.error)); | |
| 127 | return track.error; | |
| 128 | } |
src/arena.ts +3
| @@ -264,6 +264,9 @@ export function snapshot(viewerId: string): string { | ||
| 264 | 264 | haste: round2(me.haste), |
| 265 | 265 | spike: me.spike, |
| 266 | 266 | planting: round2(me.planting), |
| 267 | // Your own two ways of being held still, so the browser's prediction | |
| 268 | // of your own body runs at the speed the server is about to use. | |
| 269 | defusing: me.defusing > 0, | |
| 267 | 270 | bot: me.bot, |
| 268 | 271 | }, |
| 269 | 272 | players: seen.map((p) => ({ |
src/bots.ts +11 −3
| @@ -8,8 +8,9 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { agentById, castable } from "./abilities.ts"; |
| 11 | import { acquire, aimCone, canFire, crossRate, drift, heldFor, lose } from "./aim.ts"; | |
| 11 | 12 | import { pathStep, siteAt, siteCentre, walkDistance, type Point, type Site } from "./map.ts"; |
| 12 | import { buyAbility, buyShield, buyWeapon, random, SPIKE_REACH, visible } from "./match.ts"; | |
| 13 | import { buyAbility, buyShield, buyWeapon, random, RUN_SPEED, SPIKE_REACH, visible } from "./match.ts"; | |
| 13 | 14 | import type { MatchState, Player, Slot } from "./state.ts"; |
| 14 | 15 | import { WEAPONS, type WeaponId } from "./weapons.ts"; |
| 15 | 16 | |
| @@ -158,17 +159,24 @@ export function botThink(state: MatchState, bot: Player, dt: number): void { | ||
| 158 | 159 | // --- the gun |
| 159 | 160 | bot.input.reload = bot.ammo === 0; |
| 160 | 161 | if (!target) { |
| 162 | lose(bot.aim, state.now); | |
| 161 | 163 | bot.input.fire = false; |
| 162 | 164 | if (Math.hypot(bot.input.mx, bot.input.my) > 0.01) |
| 163 | 165 | turn(bot, Math.atan2(bot.input.my, bot.input.mx), 6 * dt); |
| 164 | 166 | return; |
| 165 | 167 | } |
| 168 | // A hold costs what a hold costs: a moment to notice, a barrel that starts | |
| 169 | // wrong and settles, and more of both when the target is crossing the view. | |
| 170 | const roll = () => random(state); | |
| 171 | acquire(bot.aim, target.id, state.now, target, roll); | |
| 172 | const crossing = crossRate(bot.aim, bot, target, dt) / RUN_SPEED; | |
| 173 | const cone = aimCone(heldFor(bot.aim, state.now), crossing); | |
| 166 | 174 | const range = Math.hypot(target.x - bot.x, target.y - bot.y); |
| 167 | const aim = Math.atan2(target.y - bot.y, target.x - bot.x) + (random(state) - 0.5) * 0.06; | |
| 175 | const aim = Math.atan2(target.y - bot.y, target.x - bot.x) + drift(bot.aim, cone, dt, roll); | |
| 168 | 176 | turn(bot, aim, 7 * dt); |
| 169 | 177 | const off = Math.abs(((aim - bot.angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI); |
| 170 | 178 | const weapon = WEAPONS[bot.weapon]; |
| 171 | bot.input.fire = off < 0.07 && range < weapon.range && bot.ammo > 0; | |
| 179 | bot.input.fire = canFire(bot.aim, state.now) && off < 0.07 && range < weapon.range && bot.ammo > 0; | |
| 172 | 180 | // Stand still to shoot at range, close the gap up front. |
| 173 | 181 | if (range > 420) { |
| 174 | 182 | bot.input.mx *= 0.15; |
src/client.test.ts +59 −3
| @@ -21,6 +21,10 @@ const base = `http://${server.hostname}:${server.port}`; | ||
| 21 | 21 | // ------------------------------------------------------------ the stub DOM |
| 22 | 22 | |
| 23 | 23 | const drawn: string[] = []; |
| 24 | // Where the canvas was moved to: on the top-down view your own body is an | |
| 25 | // arrow drawn about the origin, so the last translate of a frame is where | |
| 26 | // this client thinks you are. | |
| 27 | const moves: { x: number; y: number }[] = []; | |
| 24 | 28 | const listeners = new Map<string, ((event: unknown) => void)[]>(); |
| 25 | 29 | const frames: ((now: number) => void)[] = []; |
| 26 | 30 | |
| @@ -36,7 +40,10 @@ const context = () => | ||
| 36 | 40 | if (prop in target) return target[prop]; |
| 37 | 41 | if (prop === "fillStyle" || prop === "strokeStyle" || prop === "font" || prop === "textAlign") return ""; |
| 38 | 42 | if (prop === "globalAlpha" || prop === "lineWidth") return 1; |
| 39 | return (...args: unknown[]) => drawn.push(`${prop}(${args.length})`); | |
| 43 | return (...args: unknown[]) => { | |
| 44 | if (prop === "translate") moves.push({ x: Number(args[0]), y: Number(args[1]) }); | |
| 45 | return drawn.push(`${prop}(${args.length})`); | |
| 46 | }; | |
| 40 | 47 | }, |
| 41 | 48 | set: () => true, |
| 42 | 49 | }, |
| @@ -97,6 +104,9 @@ const click = (node: Record<string, unknown>) => { | ||
| 97 | 104 | const press = (code: string, key = "") => { |
| 98 | 105 | for (const fn of listeners.get("keydown") ?? []) fn({ code, key, target: {}, preventDefault: noop }); |
| 99 | 106 | }; |
| 107 | const release = (code: string) => { | |
| 108 | for (const fn of listeners.get("keyup") ?? []) fn({ code }); | |
| 109 | }; | |
| 100 | 110 | |
| 101 | 111 | const nodes = new Map<string, Record<string, unknown>>(); |
| 102 | 112 | for (const id of [ |
| @@ -160,9 +170,9 @@ globalThis.WebSocket = function (url: string) { | ||
| 160 | 170 | return openedSocket; |
| 161 | 171 | } as unknown as typeof WebSocket; |
| 162 | 172 | |
| 163 | const runFrame = () => { | |
| 173 | const runFrame = (at = performance.now()) => { | |
| 164 | 174 | const pending = frames.splice(0, frames.length); |
| 165 | for (const fn of pending) fn(performance.now()); | |
| 175 | for (const fn of pending) fn(at); | |
| 166 | 176 | }; |
| 167 | 177 | const until = async (what: () => boolean, ms = 4000) => { |
| 168 | 178 | const stop = Date.now() + ms; |
| @@ -274,6 +284,52 @@ test("the bundled client connects, paints the HUD and draws a frame", async () = | ||
| 274 | 284 | // And the socket really took a seat in the running match. |
| 275 | 285 | expect(seat.bot).toBe(false); |
| 276 | 286 | |
| 287 | // Your own body is drawn from a guess about your own movement, not from | |
| 288 | // the last thing the server said. With the arena stopped — no snapshot can | |
| 289 | // arrive at all — the drawn body still carries the keys you are holding | |
| 290 | // forward, as far as the cap and no further, and stops there. | |
| 291 | press("KeyV"); // the top-down view, where your own body is an arrow | |
| 292 | for (let i = 0; i < 60 && arena.match().phase !== "live"; i++) arena.advance(1); | |
| 293 | await Bun.sleep(60); | |
| 294 | runFrame(); | |
| 295 | arena.stopArena(); | |
| 296 | // Walk whichever way is open from where the server last put us. | |
| 297 | for (const code of ["KeyW", "KeyA", "KeyS", "KeyD"]) release(code); | |
| 298 | const { blockedCircle } = await import("./map.ts"); | |
| 299 | const clear = ([ | |
| 300 | ["KeyW", 0, -1], | |
| 301 | ["KeyS", 0, 1], | |
| 302 | ["KeyA", -1, 0], | |
| 303 | ["KeyD", 1, 0], | |
| 304 | ] as const).find(([, dx, dy]) => | |
| 305 | [20, 40, 60, 80].every((step) => !blockedCircle(seat.x + dx * step, seat.y + dy * step, 16)), | |
| 306 | )!; | |
| 307 | expect(clear).toBeDefined(); | |
| 308 | press(clear[0]); | |
| 309 | const arrow = () => moves[moves.length - 1]!; | |
| 310 | const startedAt = { x: seat.x, y: seat.y }; | |
| 311 | const scale = 900 / (30 * 80); // canvas pixels to a world unit | |
| 312 | const t0 = performance.now(); | |
| 313 | for (let i = 1; i <= 10; i++) { | |
| 314 | moves.length = 0; | |
| 315 | runFrame(t0 + i * 40); | |
| 316 | } | |
| 317 | const drawnAt = arrow(); | |
| 318 | // The drawn body is ahead of the server's last word by the input held | |
| 319 | // since it was sent — a quarter of a second of running at most, and then | |
| 320 | // it waits, because past that a guess would be a claim. Smoothing toward | |
| 321 | // a snapshot that cannot arrive would have gone nowhere at all. | |
| 322 | const lead = (drawnAt.x / scale - seat.x) * clear[1] + (drawnAt.y / scale - seat.y) * clear[2]; | |
| 323 | const sideways = (drawnAt.x / scale - seat.x) * clear[2] - (drawnAt.y / scale - seat.y) * clear[1]; | |
| 324 | expect(lead).toBeGreaterThan(30); | |
| 325 | expect(lead).toBeLessThan(0.25 * 250 + 10); | |
| 326 | expect(Math.abs(sideways)).toBeLessThan(6); | |
| 327 | // Nothing on the server moved: this is a drawing, and the match is still | |
| 328 | // the only thing that says where anybody is. | |
| 329 | expect(seat.x).toBe(startedAt.x); | |
| 330 | expect(seat.y).toBe(startedAt.y); | |
| 331 | arena.startArena(); | |
| 332 | ||
| 277 | 333 | // This test's loopback address is shared with every other test in the |
| 278 | 334 | // run; close the socket so the address is free for the next real |
| 279 | 335 | // connection rather than left holding a seat for the rest of the suite. |
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 +14 −8
| @@ -9,6 +9,8 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import { cast, castable, agentById, buyableSlots, flashDuration, sightBlocked, SLOTS } from "./abilities.ts"; |
| 12 | import { emptyTrack } from "./aim.ts"; | |
| 13 | import { BODY_R, moveSpeed, RUN } from "./glide.ts"; | |
| 12 | 14 | import { award, clampCredits, roundReward, spend, START_CREDITS } from "./economy.ts"; |
| 13 | 15 | import { |
| 14 | 16 | blockedCircle, |
| @@ -24,8 +26,10 @@ import { | ||
| 24 | 26 | import { emptyInput, type Input, type MatchState, type Player, type Side, type Slot, type Team } from "./state.ts"; |
| 25 | 27 | import { applyDamage, damage, shotSpread, SHIELD, WEAPONS, zoneFromMiss, type WeaponId } from "./weapons.ts"; |
| 26 | 28 | |
| 27 | export const PLAYER_R = 16; | |
| 28 | 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; | |
| 29 | 33 | export const BUY_TIME = 14; |
| 30 | 34 | export const ROUND_TIME = 100; |
| 31 | 35 | export const PLANT_TIME = 4; |
| @@ -137,6 +141,7 @@ export function makePlayer( | ||
| 137 | 141 | input: emptyInput(), |
| 138 | 142 | goal: null, |
| 139 | 143 | think: 0, |
| 144 | aim: emptyTrack(), | |
| 140 | 145 | spray: 0, |
| 141 | 146 | joinedAt: 0, |
| 142 | 147 | }; |
| @@ -229,6 +234,7 @@ export function startRound(state: MatchState, first = false): void { | ||
| 229 | 234 | player.spray = 0; |
| 230 | 235 | player.goal = null; |
| 231 | 236 | player.think = 0; |
| 237 | player.aim = emptyTrack(); | |
| 232 | 238 | player.input = emptyInput(); |
| 233 | 239 | // Basics are bought fresh every round; the signature comes back free. |
| 234 | 240 | player.charges = { c: 0, q: 0, e: 1, x: 0 }; |
| @@ -345,12 +351,12 @@ export function tick(state: MatchState, dt: number): void { | ||
| 345 | 351 | } |
| 346 | 352 | |
| 347 | 353 | function speedOf(player: Player): number { |
| 348 | let speed = RUN_SPEED; | |
| 349 | if (player.haste > 0) speed *= 1.35; | |
| 350 | if (player.slow > 0) speed *= 0.55; | |
| 351 | if (player.planting > 0 || player.defusing > 0) speed = 0; | |
| 352 | if (player.weapon === "operator") speed *= 0.86; | |
| 353 | 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 | }); | |
| 354 | 360 | } |
| 355 | 361 | |
| 356 | 362 | function movePlayer(state: MatchState, player: Player, dt: number, penned: boolean): void { |
src/predict.test.ts added +151
| @@ -0,0 +1,151 @@ | ||
| 1 | /** | |
| 2 | * The browser's guess about where you are, checked against the server's | |
| 3 | * answer: the same maths, the same wall, and a disagreement that closes. | |
| 4 | */ | |
| 5 | ||
| 6 | import { expect, test } from "bun:test"; | |
| 7 | import { BODY_R, moveSpeed } from "./glide.ts"; | |
| 8 | import { moveCircle, solidTile, TILE } from "./map.ts"; | |
| 9 | import { advance, anchor, holdBack, insideWall, LEAD_MAX, makeLocal, project, reconcile, SNAP } from "../static/predict.js"; | |
| 10 | ||
| 11 | // The world the browser builds out of map.json, which is the map the server | |
| 12 | // plays on: the same rows, the same tile, the same body. | |
| 13 | const world = { solid: solidTile, tile: TILE, r: BODY_R }; | |
| 14 | ||
| 15 | test("nothing is drawn until the server has said where you are", () => { | |
| 16 | const local = makeLocal(); | |
| 17 | expect(local.ready).toBe(false); | |
| 18 | advance(local, 1, 0, 250, 1 / 60, world); | |
| 19 | expect(local).toMatchObject({ x: 0, y: 0 }); | |
| 20 | reconcile(local, { x: 1200, y: 640 }, 1 / 60); | |
| 21 | expect(local).toMatchObject({ x: 1200, y: 640, ready: true }); | |
| 22 | }); | |
| 23 | ||
| 24 | test("a frame of movement is the speed it was given, in the direction it was given", () => { | |
| 25 | const local = anchor(makeLocal(), { x: 1200, y: 640 }); | |
| 26 | advance(local, 1, 0, 250, 1 / 50, world); | |
| 27 | expect(local.x).toBeCloseTo(1205, 6); | |
| 28 | expect(local.y).toBeCloseTo(640, 6); | |
| 29 | // A diagonal is a direction, not a speed boost. | |
| 30 | const diagonal = anchor(makeLocal(), { x: 1200, y: 640 }); | |
| 31 | advance(diagonal, 1, 1, 250, 1 / 50, world); | |
| 32 | expect(Math.hypot(diagonal.x - 1200, diagonal.y - 640)).toBeCloseTo(5, 6); | |
| 33 | // Standing still, planted, or stopped to defuse: nothing moves. | |
| 34 | const still = anchor(makeLocal(), { x: 1200, y: 640 }); | |
| 35 | advance(still, 0, 0, 250, 1 / 50, world); | |
| 36 | advance(still, 1, 0, moveSpeed({ stopped: true }), 1 / 50, world); | |
| 37 | expect(still).toMatchObject({ x: 1200, y: 640 }); | |
| 38 | }); | |
| 39 | ||
| 40 | test("the guess resolves a wall exactly the way the server does", () => { | |
| 41 | // Every open tile of the real map, walked into from eight directions: the | |
| 42 | // browser's step and the server's land in the same place, or the camera | |
| 43 | // spends the round being dragged off whatever it guessed. | |
| 44 | const rows = 19; | |
| 45 | const cols = 30; | |
| 46 | let tried = 0; | |
| 47 | for (let row = 1; row < rows - 1; row++) | |
| 48 | for (let col = 1; col < cols - 1; col++) { | |
| 49 | if (solidTile(col, row)) continue; | |
| 50 | const x = col * TILE + TILE / 2; | |
| 51 | const y = row * TILE + TILE / 2; | |
| 52 | for (const [mx, my] of [ | |
| 53 | [1, 0], | |
| 54 | [-1, 0], | |
| 55 | [0, 1], | |
| 56 | [0, -1], | |
| 57 | [0.7, 0.7], | |
| 58 | [-0.7, 0.7], | |
| 59 | [0.7, -0.7], | |
| 60 | [-0.7, -0.7], | |
| 61 | ]) { | |
| 62 | const local = anchor(makeLocal(), { x, y }); | |
| 63 | advance(local, mx, my, 250, 1 / 15, world); | |
| 64 | const server = moveCircle(x, y, mx * 250 * (1 / 15), my * 250 * (1 / 15), BODY_R); | |
| 65 | expect(local.x).toBeCloseTo(server.x, 9); | |
| 66 | expect(local.y).toBeCloseTo(server.y, 9); | |
| 67 | tried += 1; | |
| 68 | } | |
| 69 | } | |
| 70 | expect(tried).toBeGreaterThan(1000); | |
| 71 | }); | |
| 72 | ||
| 73 | test("a disagreement is leaned off, not jumped", () => { | |
| 74 | const local = anchor(makeLocal(), { x: 1200, y: 640 }); | |
| 75 | reconcile(local, { x: 1210, y: 640 }, 1 / 60); | |
| 76 | expect(local.gap).toBeCloseTo(10, 6); | |
| 77 | // A frame moves a fraction of the way, never the whole way. | |
| 78 | expect(local.x).toBeGreaterThan(1200); | |
| 79 | expect(local.x).toBeLessThan(1203); | |
| 80 | // Held there, it closes. | |
| 81 | for (let i = 0; i < 60; i++) reconcile(local, { x: 1210, y: 640 }, 1 / 60); | |
| 82 | expect(local.x).toBeCloseTo(1210, 2); | |
| 83 | }); | |
| 84 | ||
| 85 | test("a real disagreement is taken outright: the server is the answer", () => { | |
| 86 | const local = anchor(makeLocal(), { x: 1200, y: 640 }); | |
| 87 | // A spawn, a teleport, a seat handed to somebody else mid-round. | |
| 88 | reconcile(local, { x: 1200 + SNAP + 1, y: 640 }, 1 / 60); | |
| 89 | expect(local.x).toBe(1200 + SNAP + 1); | |
| 90 | expect(local.gap).toBe(0); | |
| 91 | }); | |
| 92 | ||
| 93 | test("a snapshot is carried forward by the input held since it was sent", () => { | |
| 94 | const server = { x: 1200, y: 640 }; | |
| 95 | // Holding nothing, the answer is the answer: no lead, nothing invented. | |
| 96 | expect(project(server, 0, 0, 250, 0.1, world)).toEqual({ x: 1200, y: 640 }); | |
| 97 | expect(project(server, 1, 0, 0, 0.1, world)).toEqual({ x: 1200, y: 640 }); | |
| 98 | expect(project(server, 1, 0, 250, 0, world)).toEqual({ x: 1200, y: 640 }); | |
| 99 | // Holding east for 66ms, it is 66ms of running east ahead. | |
| 100 | const ahead = project(server, 1, 0, 250, 1 / 15, world); | |
| 101 | expect(ahead.x - 1200).toBeCloseTo(250 / 15, 6); | |
| 102 | // A connection that stopped talking is not a licence to run off the map. | |
| 103 | const far = project(server, 1, 0, 250, 30, world); | |
| 104 | expect(far.x - 1200).toBeLessThanOrEqual(250 * LEAD_MAX); | |
| 105 | // And the carry is the server's own step-and-slide: never through a wall. | |
| 106 | for (let y = 100; y < 1400; y += 37) { | |
| 107 | const from = { x: 300, y }; | |
| 108 | if (insideWall(from, world)) continue; // not a place anybody can stand | |
| 109 | expect(insideWall(project(from, -1, 0, 250, LEAD_MAX, world), world)).toBe(false); | |
| 110 | } | |
| 111 | }); | |
| 112 | ||
| 113 | test("the guess is never drawn past the answer it is running toward", () => { | |
| 114 | const target = { x: 1300, y: 640 }; | |
| 115 | // Ahead of the target along the way it is going: pulled back onto it. | |
| 116 | const past = anchor(makeLocal(), { x: 1320, y: 640 }); | |
| 117 | holdBack(past, target, 1, 0); | |
| 118 | expect(past.x).toBeCloseTo(1300, 9); | |
| 119 | // Short of it, or across it, or standing still: left alone. | |
| 120 | const short = anchor(makeLocal(), { x: 1280, y: 648 }); | |
| 121 | holdBack(short, target, 1, 0); | |
| 122 | expect(short).toMatchObject({ x: 1280, y: 648 }); | |
| 123 | const still = anchor(makeLocal(), { x: 1320, y: 640 }); | |
| 124 | holdBack(still, target, 0, 0); | |
| 125 | expect(still.x).toBe(1320); | |
| 126 | }); | |
| 127 | ||
| 128 | test("a guess running with the server stays within a stride of it, and never inside a wall", () => { | |
| 129 | // Sixty frames a second guessing, fifteen a second corrected, walking east | |
| 130 | // into the wall that bounds mid. This is the case that used to skip. | |
| 131 | const speed = 250; | |
| 132 | const start = { x: 1200, y: 640 }; | |
| 133 | let server = { ...start }; | |
| 134 | const local = anchor(makeLocal(), start); | |
| 135 | let worst = 0; | |
| 136 | let back = 0; | |
| 137 | let previous = local.x; | |
| 138 | for (let frame = 0; frame < 240; frame++) { | |
| 139 | advance(local, 1, 0, speed, 1 / 60, world); | |
| 140 | // The server ticks at 30Hz and is heard from at 15Hz. | |
| 141 | if (frame % 2 === 0) server = moveCircle(server.x, server.y, speed / 30, 0, BODY_R); | |
| 142 | if (frame % 4 === 0) reconcile(local, server, 1 / 15); | |
| 143 | worst = Math.max(worst, Math.hypot(local.x - server.x, local.y - server.y)); | |
| 144 | // The drawn body never walks backwards: that is what a skip looks like. | |
| 145 | back = Math.min(back, local.x - previous); | |
| 146 | previous = local.x; | |
| 147 | expect(insideWall(local, world)).toBe(false); | |
| 148 | } | |
| 149 | expect(worst).toBeLessThan(BODY_R); | |
| 150 | expect(back).toBeGreaterThan(-0.01); | |
| 151 | }); |
src/state.ts +3
| @@ -3,6 +3,7 @@ | ||
| 3 | 3 | * can both speak about a player without importing each other. |
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | import type { Track } from "./aim.ts"; | |
| 6 | 7 | import type { Point, Site } from "./map.ts"; |
| 7 | 8 | import type { WeaponId } from "./weapons.ts"; |
| 8 | 9 | |
| @@ -97,6 +98,8 @@ export type Player = { | ||
| 97 | 98 | /** Bot scratch space: where it is walking and when it last thought. */ |
| 98 | 99 | goal: Point | null; |
| 99 | 100 | think: number; |
| 101 | /** Bot scratch space: the target it is holding, and how badly. */ | |
| 102 | aim: Track; | |
| 100 | 103 | spray: number; |
| 101 | 104 | joinedAt: number; |
| 102 | 105 | }; |
static/predict.js added +115
| @@ -0,0 +1,115 @@ | ||
| 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 | export const makeLocal = () => ({ x: 0, y: 0, ready: false, gap: 0 }); | |
| 28 | ||
| 29 | /** Take the server's word for it outright. */ | |
| 30 | export function anchor(local, at) { | |
| 31 | local.x = at.x; | |
| 32 | local.y = at.y; | |
| 33 | local.ready = true; | |
| 34 | local.gap = 0; | |
| 35 | return local; | |
| 36 | } | |
| 37 | ||
| 38 | /** | |
| 39 | * One frame of your own movement: a direction, the speed the server will | |
| 40 | * move you at, and the world to slide against — `{ solid, tile, r }`. | |
| 41 | */ | |
| 42 | export function advance(local, mx, my, speed, dt, world) { | |
| 43 | if (!local.ready) return local; | |
| 44 | const len = Math.hypot(mx, my); | |
| 45 | if (len > 1) { | |
| 46 | mx /= len; | |
| 47 | my /= len; | |
| 48 | } | |
| 49 | if (speed <= 0 || (mx === 0 && my === 0) || dt <= 0) return local; | |
| 50 | const to = slideCircle(local.x, local.y, mx * speed * dt, my * speed * dt, world.r, world.solid, world.tile); | |
| 51 | local.x = to.x; | |
| 52 | local.y = to.y; | |
| 53 | return local; | |
| 54 | } | |
| 55 | ||
| 56 | /** | |
| 57 | * Where the server's last answer has got to by now. | |
| 58 | * | |
| 59 | * A snapshot is a position from a moment ago, and the input you have held | |
| 60 | * since it was sent has not reached it yet. Rather than a sequence number | |
| 61 | * the server echoes back, the unacknowledged part of your own movement is | |
| 62 | * measured in time: the age of the snapshot, capped, walked through the | |
| 63 | * same slide. Hold nothing and this is the server's answer exactly, which | |
| 64 | * is the point — it is what the drawn body settles onto. | |
| 65 | */ | |
| 66 | export function project(at, mx, my, speed, age, world) { | |
| 67 | const len = Math.hypot(mx, my); | |
| 68 | if (len > 1) { | |
| 69 | mx /= len; | |
| 70 | my /= len; | |
| 71 | } | |
| 72 | const ahead = Math.min(LEAD_MAX, Math.max(0, age)); | |
| 73 | if (speed <= 0 || ahead === 0 || (mx === 0 && my === 0)) return { x: at.x, y: at.y }; | |
| 74 | return slideCircle(at.x, at.y, mx * speed * ahead, my * speed * ahead, world.r, world.solid, world.tile); | |
| 75 | } | |
| 76 | ||
| 77 | /** | |
| 78 | * Lean the drawn body toward the server's answer. A small disagreement — | |
| 79 | * the width of the guess, a shot of lag — is walked off over a moment; a | |
| 80 | * large one is not a disagreement but a different place, and is taken. | |
| 81 | */ | |
| 82 | export function reconcile(local, server, dt) { | |
| 83 | if (!local.ready) return anchor(local, server); | |
| 84 | const dx = server.x - local.x; | |
| 85 | const dy = server.y - local.y; | |
| 86 | local.gap = Math.hypot(dx, dy); | |
| 87 | if (local.gap > SNAP) return anchor(local, server); | |
| 88 | const k = 1 - Math.exp(-PULL * Math.max(0, dt)); | |
| 89 | local.x += dx * k; | |
| 90 | local.y += dy * k; | |
| 91 | return local; | |
| 92 | } | |
| 93 | ||
| 94 | /** | |
| 95 | * Never past the answer. The guess may fill the gap to where the server's | |
| 96 | * word and your own held keys put you and no further: left to run, a guess | |
| 97 | * that moves faster than its target is corrected finds its own equilibrium | |
| 98 | * somewhere beyond it, which is a claim about a position rather than a | |
| 99 | * drawing of one. | |
| 100 | */ | |
| 101 | export function holdBack(local, target, mx, my) { | |
| 102 | const len = Math.hypot(mx, my); | |
| 103 | if (len === 0) return local; | |
| 104 | const dx = mx / len; | |
| 105 | const dy = my / len; | |
| 106 | const over = (local.x - target.x) * dx + (local.y - target.y) * dy; | |
| 107 | if (over > 0) { | |
| 108 | local.x -= dx * over; | |
| 109 | local.y -= dy * over; | |
| 110 | } | |
| 111 | return local; | |
| 112 | } | |
| 113 | ||
| 114 | /** A body drawn inside a wall is worse than a body drawn late. */ | |
| 115 | export const insideWall = (local, world) => circleBlocked(local.x, local.y, world.r, world.solid, world.tile); |
static/val.js +88 −26
| @@ -12,7 +12,9 @@ | ||
| 12 | 12 | "use strict"; |
| 13 | 13 | |
| 14 | 14 | import { makeBeeper } from "./beep.js"; |
| 15 | import { BODY_R, moveSpeed } from "../src/glide.ts"; | |
| 15 | 16 | import { drawGun, gunSnapshot, gunTick, makeGun } from "./gun.js"; |
| 17 | import { advance, anchor, holdBack, insideWall, makeLocal, project, reconcile } from "./predict.js"; | |
| 16 | 18 | import { choosePrompt, REACH } from "./prompt.js"; |
| 17 | 19 | import { roster, rosterKey } from "./roster.js"; |
| 18 | 20 | import { AGENT_COLOUR, drawScene, makeScene } from "./scene.js"; |
| @@ -30,6 +32,7 @@ const HELP_SEEN = "val.help.seen"; | ||
| 30 | 32 | |
| 31 | 33 | let map = null; |
| 32 | 34 | let solid = () => true; |
| 35 | let world = null; // what predict.js slides against: the grid, from map.json | |
| 33 | 36 | let snap = null; |
| 34 | 37 | let socket = null; |
| 35 | 38 | let agents = null; |
| @@ -45,8 +48,17 @@ let stick = null; // { id, ox, oy, mx, my } | ||
| 45 | 48 | let aimTouch = null; |
| 46 | 49 | let helpShown = false; |
| 47 | 50 | let last = performance.now(); |
| 51 | let snapAt = last; // when the last snapshot landed: how stale the answer is | |
| 48 | 52 | |
| 49 | 53 | const walk = { x: 0, y: 0 }; |
| 54 | // What this person is doing right now, read once a frame for the drawing and | |
| 55 | // once every 50ms for the server. The same object either way: no allocation | |
| 56 | // in a loop that runs sixty times a second. | |
| 57 | const intent = { mx: 0, my: 0, angle: 0, fire: false, use: false, reload: false }; | |
| 58 | // Your own body, run forward from your own keys between snapshots, and the | |
| 59 | // object the drawing is handed for it. | |
| 60 | const local = makeLocal(); | |
| 61 | const drawnMe = { id: "", x: 0, y: 0, angle: 0 }; | |
| 50 | 62 | const scene = makeScene(); |
| 51 | 63 | const gun = makeGun(); |
| 52 | 64 | const beeper = makeBeeper(); |
| @@ -66,6 +78,7 @@ function connect() { | ||
| 66 | 78 | if (data.t !== "s") return; |
| 67 | 79 | const first = snap === null; |
| 68 | 80 | snap = data; |
| 81 | snapAt = performance.now(); | |
| 69 | 82 | if (first && mode === "fp") yaw = data.me.angle; |
| 70 | 83 | gunSnapshot(gun, data); |
| 71 | 84 | paintHud(data); |
| @@ -206,11 +219,10 @@ view.addEventListener("pointerup", endPointer); | ||
| 206 | 219 | view.addEventListener("pointercancel", endPointer); |
| 207 | 220 | view.addEventListener("contextmenu", (event) => event.preventDefault()); |
| 208 | 221 | |
| 209 | // Twenty times a second the server hears what this person is doing. In the | |
| 210 | // first-person view the keys are read against the way you are facing; in the | |
| 211 | // top-down view they are the compass, the way they always were. | |
| 212 | setInterval(() => { | |
| 213 | if (!snap || !map) return; | |
| 222 | // What this person is doing, in the world's terms. In the first-person view | |
| 223 | // the keys are read against the way you are facing; in the top-down view they | |
| 224 | // are the compass, the way they always were. | |
| 225 | function readIntent() { | |
| 214 | 226 | let ax = 0; |
| 215 | 227 | let ay = 0; |
| 216 | 228 | if (keys.has("KeyW")) ay -= 1; |
| @@ -221,31 +233,31 @@ setInterval(() => { | ||
| 221 | 233 | ax = stick.mx; |
| 222 | 234 | ay = stick.my; |
| 223 | 235 | } |
| 224 | let mx = ax; | |
| 225 | let my = ay; | |
| 226 | let angle; | |
| 236 | intent.mx = ax; | |
| 237 | intent.my = ay; | |
| 227 | 238 | if (mode === "fp") { |
| 228 | if (keys.has("ArrowLeft")) yaw -= 0.045; | |
| 229 | if (keys.has("ArrowRight")) yaw += 0.045; | |
| 230 | angle = yaw; | |
| 239 | intent.angle = yaw; | |
| 231 | 240 | moveVector(ax, ay, yaw, walk); |
| 232 | mx = walk.x; | |
| 233 | my = walk.y; | |
| 241 | intent.mx = walk.x; | |
| 242 | intent.my = walk.y; | |
| 234 | 243 | } else { |
| 235 | 244 | const aim = aimTouch || pointer; |
| 236 | const me = worldToScreen(snap.me); | |
| 237 | angle = aim ? Math.atan2(aim.y - me.y, aim.x - me.x) : snap.me.angle; | |
| 238 | yaw = angle; | |
| 245 | const me = worldToScreen(drawnMe.id ? drawnMe : snap.me); | |
| 246 | intent.angle = aim ? Math.atan2(aim.y - me.y, aim.x - me.x) : snap.me.angle; | |
| 247 | yaw = intent.angle; | |
| 239 | 248 | } |
| 240 | send({ | |
| 241 | t: "i", | |
| 242 | mx, | |
| 243 | my, | |
| 244 | a: angle, | |
| 245 | f: firing || keys.has("Space"), | |
| 246 | u: keys.has("KeyF"), | |
| 247 | r: keys.has("KeyR"), | |
| 248 | }); | |
| 249 | intent.fire = firing || keys.has("Space"); | |
| 250 | intent.use = keys.has("KeyF"); | |
| 251 | intent.reload = keys.has("KeyR"); | |
| 252 | return intent; | |
| 253 | } | |
| 254 | ||
| 255 | // Twenty times a second the server hears it. Intent only — a direction, an | |
| 256 | // angle, a held key — never a position: the guess below is for drawing. | |
| 257 | setInterval(() => { | |
| 258 | if (!snap || !map) return; | |
| 259 | const now = readIntent(); | |
| 260 | send({ t: "i", mx: now.mx, my: now.my, a: now.angle, f: now.fire, u: now.use, r: now.reload }); | |
| 249 | 261 | }, 50); |
| 250 | 262 | |
| 251 | 263 | // ------------------------------------------------------------------ drawing |
| @@ -275,7 +287,49 @@ function smooth(id, x, y, angle) { | ||
| 275 | 287 | return now; |
| 276 | 288 | } |
| 277 | 289 | |
| 278 | const look = (p) => smooth(p.id, p.x, p.y, p.angle); | |
| 290 | /** | |
| 291 | * Everybody else is smoothed toward the last position the server sent. | |
| 292 | * You are not: your own body is the one this browser can run forward | |
| 293 | * honestly, and smoothing it is what dragged the camera backwards. | |
| 294 | */ | |
| 295 | const look = (p) => (predicting() && p.id === snap.me.id ? drawnMe : smooth(p.id, p.x, p.y, p.angle)); | |
| 296 | ||
| 297 | /** | |
| 298 | * Your own movement is only guessed at while the round is live and you are | |
| 299 | * alive. The buy phase pens everybody in their own spawn — a rule of the | |
| 300 | * match, not of the map — and a guess that does not know it would spend the | |
| 301 | * phase being pulled back through the door. | |
| 302 | */ | |
| 303 | const predicting = () => snap !== null && world !== null && snap.phase !== "buy" && snap.me.alive; | |
| 304 | ||
| 305 | /** The server's answer, caught up to now, then this frame of your own keys. */ | |
| 306 | function predict(dt, now) { | |
| 307 | if (!predicting()) { | |
| 308 | local.ready = false; | |
| 309 | drawnMe.id = ""; | |
| 310 | return; | |
| 311 | } | |
| 312 | const held = readIntent(); | |
| 313 | const speed = moveSpeed({ | |
| 314 | haste: snap.me.haste > 0, | |
| 315 | slow: snap.me.slow > 0, | |
| 316 | stopped: snap.me.planting > 0 || snap.me.defusing, | |
| 317 | heavy: snap.me.weapon === "operator", | |
| 318 | }); | |
| 319 | // The snapshot is a moment old and the keys held since have not reached | |
| 320 | // it; carry it that far forward and lean the drawn body onto it. | |
| 321 | const target = project(snap.me, held.mx, held.my, speed, (now - snapAt) / 1000, world); | |
| 322 | reconcile(local, target, dt); | |
| 323 | advance(local, held.mx, held.my, speed, dt, world); | |
| 324 | holdBack(local, target, held.mx, held.my); | |
| 325 | // A lean across a corner could put the drawn body in a wall; the server's | |
| 326 | // answer is never wrong, so take it rather than draw from inside one. | |
| 327 | if (insideWall(local, world)) anchor(local, snap.me); | |
| 328 | drawnMe.id = snap.me.id; | |
| 329 | drawnMe.x = local.x; | |
| 330 | drawnMe.y = local.y; | |
| 331 | drawnMe.angle = mode === "fp" ? yaw : snap.me.angle; | |
| 332 | } | |
| 279 | 333 | |
| 280 | 334 | function frame(now) { |
| 281 | 335 | requestAnimationFrame(frame); |
| @@ -289,6 +343,11 @@ function frame(now) { | ||
| 289 | 343 | drawTopdown(ctx, map, null, look, { x: 0, y: 0, w: view.width, h: view.height }); |
| 290 | 344 | return; |
| 291 | 345 | } |
| 346 | // Turning with the arrow keys belongs to the frame, at a rate a second, | |
| 347 | // rather than to whatever the input timer happens to do. | |
| 348 | if (mode === "fp" && keys.has("ArrowLeft")) yaw -= 0.9 * dt; | |
| 349 | if (mode === "fp" && keys.has("ArrowRight")) yaw += 0.9 * dt; | |
| 350 | predict(dt, now); | |
| 292 | 351 | beeper.update(snap.spike.planted, snap.spike.fuse, now / 1000); |
| 293 | 352 | const eye = look(snap.me); |
| 294 | 353 | const facing = mode === "fp" ? yaw : eye.angle; |
| @@ -606,6 +665,9 @@ Promise.all([fetch("/map.json").then((r) => r.json()), fetch("/agents.json").the | ||
| 606 | 665 | const cols = rows[0].length; |
| 607 | 666 | solid = (col, row) => |
| 608 | 667 | col < 0 || row < 0 || row >= rows.length || col >= cols || rows[row][col] === "#"; |
| 668 | // The same grid the server collides against, so a guess about your own | |
| 669 | // movement lands where the server's answer is about to. | |
| 670 | world = { solid, tile: map.tile, r: BODY_R }; | |
| 609 | 671 | $("stage").dataset.mode = mode; |
| 610 | 672 | fillHelp(); |
| 611 | 673 | resize(); |