/** * Guns: the damage table and what a shot does with it. Pure functions — * nothing here knows about a match. * * Distance is in world units; fifty units is a metre, so the map is about * forty-eight metres across and the Phantom's fifteen-metre falloff lands * where it should. The rule that matters: a Vandal head shot kills through * full armour at any range, a Phantom head shot does not. */ export type WeaponId = | "classic" | "ghost" | "sheriff" | "spectre" | "phantom" | "vandal" | "operator"; export type Zone = "head" | "body" | "legs"; export type Band = { upTo: number; head: number; body: number; legs: number }; export type Weapon = { id: WeaponId; name: string; cost: number; /** Shots a second. */ rate: number; magazine: number; reload: number; /** Cone half-angle in radians when standing still, and the walking penalty. */ spread: number; movePenalty: number; range: number; auto: boolean; bands: Band[]; }; const METRE = 50; export const WEAPONS: Record = { classic: { id: "classic", name: "Classic", cost: 0, rate: 6.75, magazine: 12, reload: 1.75, spread: 0.022, movePenalty: 0.05, range: 1600, auto: false, bands: [{ upTo: Infinity, head: 78, body: 26, legs: 22 }], }, ghost: { id: "ghost", name: "Ghost", cost: 500, rate: 6.75, magazine: 15, reload: 1.5, spread: 0.016, movePenalty: 0.05, range: 1800, auto: false, bands: [ { upTo: 30 * METRE, head: 105, body: 30, legs: 26 }, { upTo: Infinity, head: 88, body: 25, legs: 21 }, ], }, sheriff: { id: "sheriff", name: "Sheriff", cost: 800, rate: 4, magazine: 6, reload: 2.25, spread: 0.02, movePenalty: 0.09, range: 2000, auto: false, bands: [ { upTo: 30 * METRE, head: 159, body: 55, legs: 46 }, { upTo: Infinity, head: 145, body: 50, legs: 42 }, ], }, spectre: { id: "spectre", name: "Spectre", cost: 1600, rate: 13.33, magazine: 30, reload: 2.5, spread: 0.03, movePenalty: 0.03, range: 1600, auto: true, bands: [ { upTo: 20 * METRE, head: 78, body: 26, legs: 22 }, { upTo: Infinity, head: 66, body: 22, legs: 18 }, ], }, phantom: { id: "phantom", name: "Phantom", cost: 2900, rate: 11, magazine: 30, reload: 2.5, spread: 0.021, movePenalty: 0.06, range: 2400, auto: true, bands: [ { upTo: 15 * METRE, head: 156, body: 39, legs: 33 }, { upTo: 30 * METRE, head: 140, body: 35, legs: 29 }, { upTo: Infinity, head: 124, body: 31, legs: 26 }, ], }, vandal: { id: "vandal", name: "Vandal", cost: 2900, rate: 9.75, magazine: 25, reload: 2.5, spread: 0.024, movePenalty: 0.07, range: 2400, auto: true, bands: [{ upTo: Infinity, head: 160, body: 40, legs: 34 }], }, operator: { id: "operator", name: "Operator", cost: 4700, rate: 0.6, magazine: 5, reload: 3.7, spread: 0.004, movePenalty: 0.2, range: 2800, auto: false, bands: [{ upTo: Infinity, head: 255, body: 150, legs: 120 }], }, }; export const weaponIds = (): WeaponId[] => Object.keys(WEAPONS) as WeaponId[]; export const isWeaponId = (value: unknown): value is WeaponId => typeof value === "string" && value in WEAPONS; /** Raw damage for a hit: the band the distance falls in, by zone. */ export function damage(id: WeaponId, zone: Zone, distance: number): number { const weapon = WEAPONS[id]; const band = weapon.bands.find((b) => distance <= b.upTo) ?? weapon.bands[weapon.bands.length - 1]!; return band[zone]; } /** * Armour absorbs point for point — a hundred health behind fifty armour is a * hundred and fifty to chew through — but it takes two thirds of each hit, * so it runs out before health does. */ export function applyDamage( hp: number, armour: number, amount: number, ): { hp: number; armour: number } { const toArmour = Math.min(armour, Math.ceil(amount * (2 / 3))); return { hp: Math.max(0, hp - (amount - toArmour)), armour: armour - toArmour }; } /** True when this hit kills outright: the test the Vandal exists to pass. */ export const isLethal = (hp: number, armour: number, amount: number): boolean => applyDamage(hp, armour, amount).hp <= 0; /** * Where a shot lands, from how close the ray passes the target's centre. * Top-down has no vertical, so precision stands in for it: dead centre is a * head shot, the outer edge is legs. */ export function zoneFromMiss(miss: number, radius: number): Zone { if (miss <= radius * 0.33) return "head"; if (miss <= radius * 0.75) return "body"; return "legs"; } /** Cone half-angle for a shot: standing still is accurate, running is not. */ export const shotSpread = (id: WeaponId, speedFraction: number): number => WEAPONS[id].spread + WEAPONS[id].movePenalty * Math.min(1, Math.max(0, speedFraction)); export const SHIELD = { light: { cost: 400, armour: 25 }, heavy: { cost: 1000, armour: 50 } };