wren/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Files b5f6bddcbc04948cd09fc3f6b819512f8c7d129c
| 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 | } |