/** * How a bot points a gun. * * The shot itself is shared with people — weapon spread, the move penalty, * the spray, the blind wobble all live in `weapons.ts` and apply to everyone * the same way. What lives here is the part a person is bad at and a bot was * not: seeing somebody, and getting the barrel onto them. * * A hold on a target has three honest costs. It takes a moment to notice * (`reaction`), the barrel starts somewhere wrong and only settles the longer * the same target is held (`aimCone`), and a target crossing the view is * harder to stay on than one walking down the barrel (`crossRate`). * * Everything here is a pure function of a track and a roll, so the caller * supplies the randomness — the match's seeded `random`, so a replay of a * seed still plays the same round. */ export type Point = { x: number; y: number }; /** One bot's hold on one target. Scratch space, carried on the player. */ export type Track = { /** The target being held, or null when nothing is in sight. */ id: string | null; /** Match time the hold started: every cost here is measured from it. */ since: number; /** Match time the target was last actually seen. */ seen: number; /** Seconds between seeing this target and being allowed to pull. */ reaction: number; /** How wrong the barrel is, in radians, signed. */ error: number; /** Where the target was last seen, to measure how it is moving. */ x: number; y: number; }; export const emptyTrack = (): Track => ({ id: null, since: 0, seen: 0, reaction: 0, error: 0, x: 0, y: 0 }); /** A bot notices somebody somewhere in here, never sooner. */ export const REACTION_MIN = 0.2; export const REACTION_MAX = 0.35; /** A hold survives a blink — a smoke edge, a corner — without re-reacting. */ export const FORGET = 0.4; /** Seconds for the cone to fall by 1/e while the same target is held. */ export const SETTLE = 0.8; /** The cone a bot settles to: under a Vandal's own spread, never zero. */ export const CONE_FLOOR = 0.008; /** What the cone opens to the instant a target is acquired (~6°). */ export const CONE_SWING = 0.105; /** What a target crossing the view at a full run adds to it (~3.4°). */ export const CONE_MOVE = 0.06; /** How fast the error wanders inside the cone, in cone-widths a second. */ export const DRIFT = 2.4; const clamp01 = (n: number): number => (n < 0 ? 0 : n > 1 ? 1 : n); /** The reaction a bot draws on acquiring a target: 200–350ms. */ export const reactionTime = (roll: number): number => REACTION_MIN + (REACTION_MAX - REACTION_MIN) * clamp01(roll); /** Seconds this target has been held. */ export const heldFor = (track: Track, now: number): number => Math.max(0, now - track.since); /** * The trigger gate, as a pure function of when acquisition happened: no * target, or a target seen less than a reaction ago, is no shot. */ export const canFire = (track: Track, now: number): boolean => track.id !== null && now - track.since >= track.reaction; /** * The half-angle the barrel may be wrong by: wide on the swing, settling * toward the floor the longer one target is held, and wider again for a * target crossing the view (`crossing` is 0 for still, 1 for a full run). */ export function aimCone(held: number, crossing: number): number { return CONE_FLOOR + CONE_SWING * Math.exp(-Math.max(0, held) / SETTLE) + CONE_MOVE * clamp01(crossing); } /** * Point the track at a target. A different target — or the same one after * the hold was forgotten — starts a fresh hold: a new reaction to serve and * an error drawn anywhere in the swing cone. */ export function acquire(track: Track, id: string, now: number, at: Point, roll: () => number): void { if (track.id !== id || now - track.seen > FORGET || now < track.since) { track.id = id; track.since = now; track.reaction = reactionTime(roll()); track.error = (roll() - 0.5) * 2 * aimCone(0, 0); track.x = at.x; track.y = at.y; } track.seen = now; } /** Nothing in sight. The hold survives a blink, then it is gone. */ export function lose(track: Track, now: number): void { if (track.id !== null && now - track.seen > FORGET) track.id = null; } /** * How fast the target is crossing the bot's line of sight, in units a * second. Walking down the barrel is easy to stay on; strafing is not, and * it is the part `atan2` to an exact position used to give away for free. */ export function crossRate(track: Track, bot: Point, at: Point, dt: number): number { const moved = { x: at.x - track.x, y: at.y - track.y }; track.x = at.x; track.y = at.y; if (dt <= 0) return 0; const ax = at.x - bot.x; const ay = at.y - bot.y; const len = Math.hypot(ax, ay) || 1; return Math.abs(moved.x * (-ay / len) + moved.y * (ax / len)) / dt; } /** * Wander the error inside the cone and hand it back. It is a held mistake, * not fresh noise every tick: noise resampled at 30Hz averages to a perfect * barrel over a burst, which is the thing that felt like an aimbot. */ export function drift(track: Track, cone: number, dt: number, roll: () => number): number { track.error += (roll() - 0.5) * 2 * cone * DRIFT * dt; track.error = Math.max(-cone, Math.min(cone, track.error)); return track.error; }