/** * Agents and their kits. * * Five agents, each with two basics bought with credits (charges last the * round), one signature that is free and comes back on a cooldown, and one * ultimate paid for in ult points. The effects themselves are a short list of * kinds — flash, smoke, molly, dart, heal, wall, trip — so an agent is data, * not code. * * The two rules worth testing on their own: a flash blinds by the angle you * were facing and only with line of sight, and a smoke blocks that sight. */ import { rayHitWall, segmentHitsCircle, wallBetween, type Point } from "./map.ts"; import type { MatchState, Player, Slot, Zone } from "./state.ts"; export type Kind = | "flash" | "smoke" | "molly" | "dart" | "heal" | "wall" | "trip" | "haste" | "rage" | "revealall" | "slowall"; export type Ability = { slot: Slot; name: string; kind: Kind; /** Credits for a basic; 0 for the signature and the ultimate. */ cost: number; charges: number; /** Seconds the signature takes to come back. 0 means it does not. */ cooldown: number; /** Ult points the ultimate costs. */ points: number; range: number; radius: number; duration: number; damage: number; fuse: number; text: string; }; export type Agent = { id: string; name: string; role: string; blurb: string; abilities: Record; }; const basic = (a: Partial & { slot: Slot; name: string; kind: Kind; text: string }): Ability => ({ cost: 200, charges: 1, cooldown: 0, points: 0, range: 700, radius: 110, duration: 10, damage: 0, fuse: 0, ...a, }); export const AGENTS: Agent[] = [ { id: "ember", name: "Ember", role: "Duelist", blurb: "Flash in, burn them off the plant, come back from nothing.", abilities: { c: basic({ slot: "c", name: "Flashfire", kind: "flash", cost: 250, charges: 2, range: 620, radius: 360, fuse: 0.55, duration: 1.7, text: "Throws a flash that pops after half a second and blinds anyone looking near it." }), q: basic({ slot: "q", name: "Blaze", kind: "molly", cost: 200, charges: 1, range: 620, radius: 95, duration: 6, damage: 26, text: "A pool of fire: 26 damage a second to anyone standing in it." }), e: basic({ slot: "e", name: "Second Wind", kind: "haste", cost: 0, charges: 1, cooldown: 35, duration: 4, text: "Signature. Four seconds of speed — take the angle before they re-peek." }), x: basic({ slot: "x", name: "Rebirth", kind: "rage", cost: 0, charges: 0, points: 6, duration: 6, text: "Ultimate. Full health, armour back, every basic charge refilled, six seconds of speed." }), }, }, { id: "veil", name: "Veil", role: "Controller", blurb: "Takes the sightline away and gives the site to whoever she likes.", abilities: { c: basic({ slot: "c", name: "Cloudburst", kind: "smoke", cost: 100, charges: 2, range: 760, radius: 105, duration: 8, text: "A short smoke. Cheap enough to throw two on a pistol round." }), q: basic({ slot: "q", name: "Blind Spot", kind: "flash", cost: 250, charges: 1, range: 600, radius: 340, fuse: 0.5, duration: 1.6, text: "A flash for the entry you are about to make." }), e: basic({ slot: "e", name: "Curtain", kind: "smoke", cost: 0, charges: 1, cooldown: 35, range: 900, radius: 150, duration: 15, text: "Signature. A wide smoke for fifteen seconds — nobody sees through it, server side." }), x: basic({ slot: "x", name: "Blackout", kind: "smoke", cost: 0, charges: 0, points: 7, range: 1100, radius: 280, duration: 12, damage: -1, text: "Ultimate. A site-sized smoke that slows every enemy inside it." }), }, }, { id: "sable", name: "Sable", role: "Initiator", blurb: "Finds them before the team walks in, and moves them off the angle.", abilities: { c: basic({ slot: "c", name: "Recon Dart", kind: "dart", cost: 250, charges: 1, range: 900, radius: 340, duration: 6, text: "Sticks and sweeps three times, revealing enemies it can see for two seconds each." }), q: basic({ slot: "q", name: "Shock Bolt", kind: "molly", cost: 150, charges: 2, range: 820, radius: 90, duration: 1.1, damage: 70, text: "A bolt that cracks for 70 a second — clears a corner or finishes a plant." }), e: basic({ slot: "e", name: "Owl Flash", kind: "flash", cost: 0, charges: 1, cooldown: 40, range: 640, radius: 360, fuse: 0.6, duration: 1.6, text: "Signature. A flash on a cooldown, so there is always one for the retake." }), x: basic({ slot: "x", name: "Hunt", kind: "revealall", cost: 0, charges: 0, points: 7, duration: 6, text: "Ultimate. Every enemy on the map is revealed for six seconds." }), }, }, { id: "mender", name: "Mender", role: "Sentinel", blurb: "Holds the flank shut and keeps the entry alive long enough to trade.", abilities: { c: basic({ slot: "c", name: "Healing Pulse", kind: "heal", cost: 200, charges: 2, range: 260, radius: 260, duration: 3, damage: 60, text: "Heals you or the nearest hurt ally 60 health over three seconds." }), q: basic({ slot: "q", name: "Barrier", kind: "wall", cost: 400, charges: 1, range: 520, radius: 120, duration: 12, text: "A solid wall for twelve seconds: nothing walks through it and nothing sees through it." }), e: basic({ slot: "e", name: "Tripwire", kind: "trip", cost: 0, charges: 1, cooldown: 40, range: 300, radius: 120, duration: 45, text: "Signature. Watches the flank: the enemy who walks in is revealed and slowed." }), x: basic({ slot: "x", name: "Lockdown", kind: "slowall", cost: 0, charges: 0, points: 8, duration: 5, text: "Ultimate. Every enemy is slowed and revealed for five seconds." }), }, }, { id: "rook", name: "Rook", role: "Initiator", blurb: "Builds the angle he wants and sets the site on fire when it is lost.", abilities: { c: basic({ slot: "c", name: "Smoke Grenade", kind: "smoke", cost: 150, charges: 1, range: 800, radius: 130, duration: 10, text: "One smoke, thrown long." }), q: basic({ slot: "q", name: "Flashbang", kind: "flash", cost: 200, charges: 2, range: 600, radius: 320, fuse: 0.6, duration: 1.5, text: "Two flashes: one for the entry, one for the exit." }), e: basic({ slot: "e", name: "Stone Wall", kind: "wall", cost: 0, charges: 1, cooldown: 45, range: 480, radius: 130, duration: 10, text: "Signature. A free wall, back every forty-five seconds." }), x: basic({ slot: "x", name: "Firestorm", kind: "molly", cost: 0, charges: 0, points: 7, range: 900, radius: 210, duration: 8, damage: 34, text: "Ultimate. A site-wide fire nobody defuses through." }), }, }, ]; export const agentById = (id: string): Agent => AGENTS.find((a) => a.id === id) ?? AGENTS[0]!; export const isAgentId = (value: unknown): value is string => typeof value === "string" && AGENTS.some((a) => a.id === value); export const SLOTS: Slot[] = ["c", "q", "e", "x"]; /** Bought before the round; the signature and the ultimate are never for sale. */ export const buyableSlots: Slot[] = ["c", "q"]; /** * The cone a flash reaches into, either side of where you are facing. * * The browser draws a 75° field of view (`FOV` in `static/raycast.js`), so * the far edge is half of that: a pop you could not have had on screen does * not blind you, because being blinded by something off screen reads as * unfair — it is. Inside `FLASH_FULL_CONE` it is the whole duration; from * there to the edge of the screen it fades to nothing. */ export const FLASH_CONE = (37.5 * Math.PI) / 180; export const FLASH_FULL_CONE = (20 * Math.PI) / 180; /** Inside this share of the radius a flash is full strength. */ export const FLASH_FULL_REACH = 0.4; /** What is left of the duration at the very edge of the radius: a flicker. */ export const FLASH_EDGE = 0.2; /** * How long a flash blinds, in seconds. Facing it is the worst case; looking * away is free; a wall or a smoke between you and it means nothing at all. * Distance matters inside the radius too: the centre is the full duration, * the edge is a flicker, and past the radius there is nothing. `blocked` is * the caller's line-of-sight answer, so this stays pure. */ export function flashDuration( full: number, from: Point, at: Point, facing: number, blocked: boolean, radius: number, ): number { if (blocked) return 0; const distance = Math.hypot(from.x - at.x, from.y - at.y); if (distance > radius) return 0; const toFlash = Math.atan2(at.y - from.y, at.x - from.x); let off = Math.abs(((toFlash - facing + Math.PI * 3) % (Math.PI * 2)) - Math.PI); off = Math.min(off, Math.PI); if (off > FLASH_CONE) return 0; const facingFactor = off <= FLASH_FULL_CONE ? 1 : 1 - (off - FLASH_FULL_CONE) / (FLASH_CONE - FLASH_FULL_CONE); const reach = radius * FLASH_FULL_REACH; const near = distance <= reach ? 1 : 1 - ((distance - reach) / (radius - reach)) * (1 - FLASH_EDGE); return Math.round(full * facingFactor * near * 100) / 100; } /** Whether a sight line survives the smokes and walls standing on the map. */ export const sightBlocked = (from: Point, to: Point, zones: Zone[]): boolean => { if (wallBetween(from, to)) return true; for (const zone of zones) if (zone.blocks && zone.fuse <= 0 && segmentHitsCircle(from, to, zone, zone.r)) return true; return false; }; /** Can this player cast this slot right now, and why not. */ export function castable(player: Player, slot: Slot): boolean { if (!player.alive) return false; const ability = agentById(player.agent).abilities[slot]; if (slot === "x") return player.ult >= ability.points; if (player.cooldown[slot] > 0) return false; return player.charges[slot] > 0; } /** Where an ability lands: down the aim line, stopped by the first wall. */ export const castPoint = (player: Player, range: number): Point => rayHitWall(player, player.angle, range); const zone = (state: MatchState, z: Omit): Zone => { const made = { ...z, id: state.nextZone++ }; state.zones.push(made); return made; }; /** * Spend the charge and put the effect on the map. Returns false when the * cast was refused — no charge, still on cooldown, not enough ult points — * so the caller never has to guess. */ export function cast(state: MatchState, player: Player, slot: Slot): boolean { if (!castable(player, slot)) return false; const agent = agentById(player.agent); const ability = agent.abilities[slot]; const spot = castPoint(player, ability.range); const base = { owner: player.id, side: player.side, life: ability.duration, fuse: ability.fuse, beat: 0, pulses: 0, damage: ability.damage, blocks: false, solid: false }; switch (ability.kind) { case "flash": zone(state, { ...base, kind: "flash", x: spot.x, y: spot.y, r: ability.radius, life: ability.fuse + 0.2, damage: ability.duration }); break; case "smoke": zone(state, { ...base, kind: "smoke", x: spot.x, y: spot.y, r: ability.radius, blocks: true }); break; case "molly": zone(state, { ...base, kind: "molly", x: spot.x, y: spot.y, r: ability.radius, beat: 0.5 }); break; case "dart": zone(state, { ...base, kind: "dart", x: spot.x, y: spot.y, r: ability.radius, beat: 0.6, pulses: 3 }); break; case "trip": zone(state, { ...base, kind: "trip", x: spot.x, y: spot.y, r: ability.radius, fuse: 1 }); break; case "heal": { const hurt = state.players .filter((p) => p.alive && p.side === player.side && p.hp < 100 && Math.hypot(p.x - player.x, p.y - player.y) <= ability.range) .sort((a, b) => a.hp - b.hp)[0]; const target = hurt ?? player; zone(state, { ...base, kind: "heal", x: target.x, y: target.y, r: 40, beat: 0.5, owner: target.id }); break; } case "wall": { // Three blockers across the aim line: solid to walk through and to see // through, and made of circles so every other test still works. const across = player.angle + Math.PI / 2; for (let i = -1; i <= 1; i++) zone(state, { ...base, kind: "wall", x: spot.x + Math.cos(across) * i * (ability.radius * 0.7), y: spot.y + Math.sin(across) * i * (ability.radius * 0.7), r: ability.radius * 0.45, blocks: true, solid: true, }); break; } case "haste": player.haste = Math.max(player.haste, ability.duration); break; case "rage": player.hp = 100; player.armour = Math.max(player.armour, 25); player.haste = Math.max(player.haste, ability.duration); for (const s of buyableSlots) player.charges[s] = Math.max(player.charges[s], agent.abilities[s].charges); break; case "revealall": for (const other of state.players) if (other.side !== player.side) other.revealed = Math.max(other.revealed, ability.duration); break; case "slowall": for (const other of state.players) if (other.side !== player.side) { other.revealed = Math.max(other.revealed, ability.duration); other.slow = Math.max(other.slow, ability.duration); } break; } if (slot === "x") player.ult = 0; else { player.charges[slot] = Math.max(0, player.charges[slot] - 1); if (ability.cooldown > 0) player.cooldown[slot] = ability.cooldown; } return true; }