fable/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Files 2345b5223e387cb66b1c8373aac8440e0c8bfdb0
| 1 | /** |
| 2 | * Agents and their kits. |
| 3 | * |
| 4 | * Five agents, each with two basics bought with credits (charges last the |
| 5 | * round), one signature that is free and comes back on a cooldown, and one |
| 6 | * ultimate paid for in ult points. The effects themselves are a short list of |
| 7 | * kinds — flash, smoke, molly, dart, heal, wall, trip — so an agent is data, |
| 8 | * not code. |
| 9 | * |
| 10 | * The two rules worth testing on their own: a flash blinds by the angle you |
| 11 | * were facing and only with line of sight, and a smoke blocks that sight. |
| 12 | */ |
| 13 | |
| 14 | import { rayHitWall, segmentHitsCircle, wallBetween, type Point } from "./map.ts"; |
| 15 | import type { MatchState, Player, Slot, Zone } from "./state.ts"; |
| 16 | |
| 17 | export type Kind = |
| 18 | | "flash" |
| 19 | | "smoke" |
| 20 | | "molly" |
| 21 | | "dart" |
| 22 | | "heal" |
| 23 | | "wall" |
| 24 | | "trip" |
| 25 | | "haste" |
| 26 | | "rage" |
| 27 | | "revealall" |
| 28 | | "slowall"; |
| 29 | |
| 30 | export type Ability = { |
| 31 | slot: Slot; |
| 32 | name: string; |
| 33 | kind: Kind; |
| 34 | /** Credits for a basic; 0 for the signature and the ultimate. */ |
| 35 | cost: number; |
| 36 | charges: number; |
| 37 | /** Seconds the signature takes to come back. 0 means it does not. */ |
| 38 | cooldown: number; |
| 39 | /** Ult points the ultimate costs. */ |
| 40 | points: number; |
| 41 | range: number; |
| 42 | radius: number; |
| 43 | duration: number; |
| 44 | damage: number; |
| 45 | fuse: number; |
| 46 | text: string; |
| 47 | }; |
| 48 | |
| 49 | export type Agent = { |
| 50 | id: string; |
| 51 | name: string; |
| 52 | role: string; |
| 53 | blurb: string; |
| 54 | abilities: Record<Slot, Ability>; |
| 55 | }; |
| 56 | |
| 57 | const basic = (a: Partial<Ability> & { slot: Slot; name: string; kind: Kind; text: string }): Ability => ({ |
| 58 | cost: 200, |
| 59 | charges: 1, |
| 60 | cooldown: 0, |
| 61 | points: 0, |
| 62 | range: 700, |
| 63 | radius: 110, |
| 64 | duration: 10, |
| 65 | damage: 0, |
| 66 | fuse: 0, |
| 67 | ...a, |
| 68 | }); |
| 69 | |
| 70 | export const AGENTS: Agent[] = [ |
| 71 | { |
| 72 | id: "ember", |
| 73 | name: "Ember", |
| 74 | role: "Duelist", |
| 75 | blurb: "Flash in, burn them off the plant, come back from nothing.", |
| 76 | abilities: { |
| 77 | 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." }), |
| 78 | 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." }), |
| 79 | 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." }), |
| 80 | 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." }), |
| 81 | }, |
| 82 | }, |
| 83 | { |
| 84 | id: "veil", |
| 85 | name: "Veil", |
| 86 | role: "Controller", |
| 87 | blurb: "Takes the sightline away and gives the site to whoever she likes.", |
| 88 | abilities: { |
| 89 | 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." }), |
| 90 | 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." }), |
| 91 | 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." }), |
| 92 | 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." }), |
| 93 | }, |
| 94 | }, |
| 95 | { |
| 96 | id: "sable", |
| 97 | name: "Sable", |
| 98 | role: "Initiator", |
| 99 | blurb: "Finds them before the team walks in, and moves them off the angle.", |
| 100 | abilities: { |
| 101 | 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." }), |
| 102 | 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." }), |
| 103 | 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." }), |
| 104 | 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." }), |
| 105 | }, |
| 106 | }, |
| 107 | { |
| 108 | id: "mender", |
| 109 | name: "Mender", |
| 110 | role: "Sentinel", |
| 111 | blurb: "Holds the flank shut and keeps the entry alive long enough to trade.", |
| 112 | abilities: { |
| 113 | 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." }), |
| 114 | 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." }), |
| 115 | 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." }), |
| 116 | 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." }), |
| 117 | }, |
| 118 | }, |
| 119 | { |
| 120 | id: "rook", |
| 121 | name: "Rook", |
| 122 | role: "Initiator", |
| 123 | blurb: "Builds the angle he wants and sets the site on fire when it is lost.", |
| 124 | abilities: { |
| 125 | c: basic({ slot: "c", name: "Smoke Grenade", kind: "smoke", cost: 150, charges: 1, range: 800, radius: 130, duration: 10, text: "One smoke, thrown long." }), |
| 126 | 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." }), |
| 127 | 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." }), |
| 128 | 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." }), |
| 129 | }, |
| 130 | }, |
| 131 | ]; |
| 132 | |
| 133 | export const agentById = (id: string): Agent => AGENTS.find((a) => a.id === id) ?? AGENTS[0]!; |
| 134 | export const isAgentId = (value: unknown): value is string => |
| 135 | typeof value === "string" && AGENTS.some((a) => a.id === value); |
| 136 | |
| 137 | export const SLOTS: Slot[] = ["c", "q", "e", "x"]; |
| 138 | /** Bought before the round; the signature and the ultimate are never for sale. */ |
| 139 | export const buyableSlots: Slot[] = ["c", "q"]; |
| 140 | |
| 141 | /** |
| 142 | * The rule, from the person playing it: if you are looking in the direction |
| 143 | * of the flash you get flashed, and if you are not, you do not. Facing is |
| 144 | * the gate. Distance only decides how long. |
| 145 | * |
| 146 | * The browser draws a 75° field of view (`FOV` in `static/engine/raycast.js`), so |
| 147 | * anything inside half of that was on your screen: you looked at it, you |
| 148 | * pay the whole duration. Past the edge of the screen there is a little |
| 149 | * grace for the fact that you were probably turning, falling away quickly |
| 150 | * to nothing by `FLASH_GRACE`. Beyond that it is behind your back and |
| 151 | * somebody else's problem — not a short blind, not a flicker, nothing. |
| 152 | */ |
| 153 | export const FLASH_CONE = (37.5 * Math.PI) / 180; |
| 154 | export const FLASH_GRACE = (55 * Math.PI) / 180; |
| 155 | /** Inside this share of the radius a flash is full strength. */ |
| 156 | export const FLASH_FULL_REACH = 0.4; |
| 157 | /** |
| 158 | * What is left of the duration at the very edge of the radius. Never zero: |
| 159 | * facing a flash exactly is not survivable because of a distance table. |
| 160 | */ |
| 161 | export const FLASH_EDGE = 0.2; |
| 162 | |
| 163 | /** |
| 164 | * Whether you were looking at it, as a share of the duration: 1 anywhere on |
| 165 | * screen, falling quickly through the grace past its edge, 0 beyond. |
| 166 | */ |
| 167 | export function facingFlash(from: Point, at: Point, facing: number): number { |
| 168 | const toFlash = Math.atan2(at.y - from.y, at.x - from.x); |
| 169 | let off = Math.abs(((toFlash - facing + Math.PI * 3) % (Math.PI * 2)) - Math.PI); |
| 170 | off = Math.min(off, Math.PI); |
| 171 | if (off <= FLASH_CONE) return 1; |
| 172 | if (off >= FLASH_GRACE) return 0; |
| 173 | const past = (off - FLASH_CONE) / (FLASH_GRACE - FLASH_CONE); |
| 174 | return (1 - past) * (1 - past); |
| 175 | } |
| 176 | |
| 177 | /** |
| 178 | * How far the light carries, as a share of the duration: full near the pop, |
| 179 | * a flicker at the edge of the radius, nothing past it. Distance decides |
| 180 | * how long, never whether. |
| 181 | */ |
| 182 | export function flashReach(distance: number, radius: number): number { |
| 183 | if (distance > radius) return 0; |
| 184 | const reach = radius * FLASH_FULL_REACH; |
| 185 | if (distance <= reach) return 1; |
| 186 | return 1 - ((distance - reach) / (radius - reach)) * (1 - FLASH_EDGE); |
| 187 | } |
| 188 | |
| 189 | /** |
| 190 | * How long a flash blinds, in seconds. Facing decides whether: on screen is |
| 191 | * the full duration, behind your back is nothing, and a wall or a smoke |
| 192 | * between you and the pop means you were not looking at it whatever your |
| 193 | * angle says. Distance decides how long, inside the radius the light |
| 194 | * carries. `blocked` is the caller's line-of-sight answer, so this stays |
| 195 | * pure. |
| 196 | */ |
| 197 | export function flashDuration( |
| 198 | full: number, |
| 199 | from: Point, |
| 200 | at: Point, |
| 201 | facing: number, |
| 202 | blocked: boolean, |
| 203 | radius: number, |
| 204 | ): number { |
| 205 | if (blocked) return 0; |
| 206 | const looking = facingFlash(from, at, facing); |
| 207 | if (looking === 0) return 0; |
| 208 | const near = flashReach(Math.hypot(from.x - at.x, from.y - at.y), radius); |
| 209 | return Math.round(full * looking * near * 100) / 100; |
| 210 | } |
| 211 | |
| 212 | /** Whether a sight line survives the smokes and walls standing on the map. */ |
| 213 | export const sightBlocked = (from: Point, to: Point, zones: Zone[]): boolean => { |
| 214 | if (wallBetween(from, to)) return true; |
| 215 | for (const zone of zones) |
| 216 | if (zone.blocks && zone.fuse <= 0 && segmentHitsCircle(from, to, zone, zone.r)) return true; |
| 217 | return false; |
| 218 | }; |
| 219 | |
| 220 | /** Can this player cast this slot right now, and why not. */ |
| 221 | export function castable(player: Player, slot: Slot): boolean { |
| 222 | if (!player.alive) return false; |
| 223 | const ability = agentById(player.agent).abilities[slot]; |
| 224 | if (slot === "x") return player.ult >= ability.points; |
| 225 | if (player.cooldown[slot] > 0) return false; |
| 226 | return player.charges[slot] > 0; |
| 227 | } |
| 228 | |
| 229 | /** Where an ability lands: down the aim line, stopped by the first wall. */ |
| 230 | export const castPoint = (player: Player, range: number): Point => |
| 231 | rayHitWall(player, player.angle, range); |
| 232 | |
| 233 | const zone = (state: MatchState, z: Omit<Zone, "id">): Zone => { |
| 234 | const made = { ...z, id: state.nextZone++ }; |
| 235 | state.zones.push(made); |
| 236 | return made; |
| 237 | }; |
| 238 | |
| 239 | /** |
| 240 | * Spend the charge and put the effect on the map. Returns false when the |
| 241 | * cast was refused — no charge, still on cooldown, not enough ult points — |
| 242 | * so the caller never has to guess. |
| 243 | */ |
| 244 | export function cast(state: MatchState, player: Player, slot: Slot): boolean { |
| 245 | if (!castable(player, slot)) return false; |
| 246 | const agent = agentById(player.agent); |
| 247 | const ability = agent.abilities[slot]; |
| 248 | const spot = castPoint(player, ability.range); |
| 249 | 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 }; |
| 250 | |
| 251 | switch (ability.kind) { |
| 252 | case "flash": |
| 253 | zone(state, { ...base, kind: "flash", x: spot.x, y: spot.y, r: ability.radius, life: ability.fuse + 0.2, damage: ability.duration }); |
| 254 | break; |
| 255 | case "smoke": |
| 256 | zone(state, { ...base, kind: "smoke", x: spot.x, y: spot.y, r: ability.radius, blocks: true }); |
| 257 | break; |
| 258 | case "molly": |
| 259 | zone(state, { ...base, kind: "molly", x: spot.x, y: spot.y, r: ability.radius, beat: 0.5 }); |
| 260 | break; |
| 261 | case "dart": |
| 262 | zone(state, { ...base, kind: "dart", x: spot.x, y: spot.y, r: ability.radius, beat: 0.6, pulses: 3 }); |
| 263 | break; |
| 264 | case "trip": |
| 265 | zone(state, { ...base, kind: "trip", x: spot.x, y: spot.y, r: ability.radius, fuse: 1 }); |
| 266 | break; |
| 267 | case "heal": { |
| 268 | const hurt = state.players |
| 269 | .filter((p) => p.alive && p.side === player.side && p.hp < 100 && Math.hypot(p.x - player.x, p.y - player.y) <= ability.range) |
| 270 | .sort((a, b) => a.hp - b.hp)[0]; |
| 271 | const target = hurt ?? player; |
| 272 | zone(state, { ...base, kind: "heal", x: target.x, y: target.y, r: 40, beat: 0.5, owner: target.id }); |
| 273 | break; |
| 274 | } |
| 275 | case "wall": { |
| 276 | // Three blockers across the aim line: solid to walk through and to see |
| 277 | // through, and made of circles so every other test still works. |
| 278 | const across = player.angle + Math.PI / 2; |
| 279 | for (let i = -1; i <= 1; i++) |
| 280 | zone(state, { |
| 281 | ...base, |
| 282 | kind: "wall", |
| 283 | x: spot.x + Math.cos(across) * i * (ability.radius * 0.7), |
| 284 | y: spot.y + Math.sin(across) * i * (ability.radius * 0.7), |
| 285 | r: ability.radius * 0.45, |
| 286 | blocks: true, |
| 287 | solid: true, |
| 288 | }); |
| 289 | break; |
| 290 | } |
| 291 | case "haste": |
| 292 | player.haste = Math.max(player.haste, ability.duration); |
| 293 | break; |
| 294 | case "rage": |
| 295 | player.hp = 100; |
| 296 | player.armour = Math.max(player.armour, 25); |
| 297 | player.haste = Math.max(player.haste, ability.duration); |
| 298 | for (const s of buyableSlots) player.charges[s] = Math.max(player.charges[s], agent.abilities[s].charges); |
| 299 | break; |
| 300 | case "revealall": |
| 301 | for (const other of state.players) |
| 302 | if (other.side !== player.side) other.revealed = Math.max(other.revealed, ability.duration); |
| 303 | break; |
| 304 | case "slowall": |
| 305 | for (const other of state.players) |
| 306 | if (other.side !== player.side) { |
| 307 | other.revealed = Math.max(other.revealed, ability.duration); |
| 308 | other.slow = Math.max(other.slow, ability.duration); |
| 309 | } |
| 310 | break; |
| 311 | } |
| 312 | |
| 313 | if (slot === "x") player.ult = 0; |
| 314 | else { |
| 315 | player.charges[slot] = Math.max(0, player.charges[slot] - 1); |
| 316 | if (ability.cooldown > 0) player.cooldown[slot] = ability.cooldown; |
| 317 | } |
| 318 | return true; |
| 319 | } |