| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 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 |
|
| 35 |
cost: number; |
| 36 |
charges: number; |
| 37 |
|
| 38 |
cooldown: number; |
| 39 |
|
| 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 |
|
| 139 |
export const buyableSlots: Slot[] = ["c", "q"]; |
| 140 |
|
| 141 |
|
| 142 |
|
| 143 |
|
| 144 |
|
| 145 |
|
| 146 |
|
| 147 |
|
| 148 |
|
| 149 |
|
| 150 |
export const FLASH_CONE = (37.5 * Math.PI) / 180; |
| 151 |
export const FLASH_FULL_CONE = (20 * Math.PI) / 180; |
| 152 |
|
| 153 |
export const FLASH_FULL_REACH = 0.4; |
| 154 |
|
| 155 |
export const FLASH_EDGE = 0.2; |
| 156 |
|
| 157 |
|
| 158 |
|
| 159 |
|
| 160 |
|
| 161 |
|
| 162 |
|
| 163 |
|
| 164 |
export function flashDuration( |
| 165 |
full: number, |
| 166 |
from: Point, |
| 167 |
at: Point, |
| 168 |
facing: number, |
| 169 |
blocked: boolean, |
| 170 |
radius: number, |
| 171 |
): number { |
| 172 |
if (blocked) return 0; |
| 173 |
const distance = Math.hypot(from.x - at.x, from.y - at.y); |
| 174 |
if (distance > radius) return 0; |
| 175 |
const toFlash = Math.atan2(at.y - from.y, at.x - from.x); |
| 176 |
let off = Math.abs(((toFlash - facing + Math.PI * 3) % (Math.PI * 2)) - Math.PI); |
| 177 |
off = Math.min(off, Math.PI); |
| 178 |
if (off > FLASH_CONE) return 0; |
| 179 |
const facingFactor = off <= FLASH_FULL_CONE ? 1 : 1 - (off - FLASH_FULL_CONE) / (FLASH_CONE - FLASH_FULL_CONE); |
| 180 |
const reach = radius * FLASH_FULL_REACH; |
| 181 |
const near = distance <= reach ? 1 : 1 - ((distance - reach) / (radius - reach)) * (1 - FLASH_EDGE); |
| 182 |
return Math.round(full * facingFactor * near * 100) / 100; |
| 183 |
} |
| 184 |
|
| 185 |
|
| 186 |
export const sightBlocked = (from: Point, to: Point, zones: Zone[]): boolean => { |
| 187 |
if (wallBetween(from, to)) return true; |
| 188 |
for (const zone of zones) |
| 189 |
if (zone.blocks && zone.fuse <= 0 && segmentHitsCircle(from, to, zone, zone.r)) return true; |
| 190 |
return false; |
| 191 |
}; |
| 192 |
|
| 193 |
|
| 194 |
export function castable(player: Player, slot: Slot): boolean { |
| 195 |
if (!player.alive) return false; |
| 196 |
const ability = agentById(player.agent).abilities[slot]; |
| 197 |
if (slot === "x") return player.ult >= ability.points; |
| 198 |
if (player.cooldown[slot] > 0) return false; |
| 199 |
return player.charges[slot] > 0; |
| 200 |
} |
| 201 |
|
| 202 |
|
| 203 |
export const castPoint = (player: Player, range: number): Point => |
| 204 |
rayHitWall(player, player.angle, range); |
| 205 |
|
| 206 |
const zone = (state: MatchState, z: Omit<Zone, "id">): Zone => { |
| 207 |
const made = { ...z, id: state.nextZone++ }; |
| 208 |
state.zones.push(made); |
| 209 |
return made; |
| 210 |
}; |
| 211 |
|
| 212 |
|
| 213 |
|
| 214 |
|
| 215 |
|
| 216 |
|
| 217 |
export function cast(state: MatchState, player: Player, slot: Slot): boolean { |
| 218 |
if (!castable(player, slot)) return false; |
| 219 |
const agent = agentById(player.agent); |
| 220 |
const ability = agent.abilities[slot]; |
| 221 |
const spot = castPoint(player, ability.range); |
| 222 |
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 }; |
| 223 |
|
| 224 |
switch (ability.kind) { |
| 225 |
case "flash": |
| 226 |
zone(state, { ...base, kind: "flash", x: spot.x, y: spot.y, r: ability.radius, life: ability.fuse + 0.2, damage: ability.duration }); |
| 227 |
break; |
| 228 |
case "smoke": |
| 229 |
zone(state, { ...base, kind: "smoke", x: spot.x, y: spot.y, r: ability.radius, blocks: true }); |
| 230 |
break; |
| 231 |
case "molly": |
| 232 |
zone(state, { ...base, kind: "molly", x: spot.x, y: spot.y, r: ability.radius, beat: 0.5 }); |
| 233 |
break; |
| 234 |
case "dart": |
| 235 |
zone(state, { ...base, kind: "dart", x: spot.x, y: spot.y, r: ability.radius, beat: 0.6, pulses: 3 }); |
| 236 |
break; |
| 237 |
case "trip": |
| 238 |
zone(state, { ...base, kind: "trip", x: spot.x, y: spot.y, r: ability.radius, fuse: 1 }); |
| 239 |
break; |
| 240 |
case "heal": { |
| 241 |
const hurt = state.players |
| 242 |
.filter((p) => p.alive && p.side === player.side && p.hp < 100 && Math.hypot(p.x - player.x, p.y - player.y) <= ability.range) |
| 243 |
.sort((a, b) => a.hp - b.hp)[0]; |
| 244 |
const target = hurt ?? player; |
| 245 |
zone(state, { ...base, kind: "heal", x: target.x, y: target.y, r: 40, beat: 0.5, owner: target.id }); |
| 246 |
break; |
| 247 |
} |
| 248 |
case "wall": { |
| 249 |
|
| 250 |
|
| 251 |
const across = player.angle + Math.PI / 2; |
| 252 |
for (let i = -1; i <= 1; i++) |
| 253 |
zone(state, { |
| 254 |
...base, |
| 255 |
kind: "wall", |
| 256 |
x: spot.x + Math.cos(across) * i * (ability.radius * 0.7), |
| 257 |
y: spot.y + Math.sin(across) * i * (ability.radius * 0.7), |
| 258 |
r: ability.radius * 0.45, |
| 259 |
blocks: true, |
| 260 |
solid: true, |
| 261 |
}); |
| 262 |
break; |
| 263 |
} |
| 264 |
case "haste": |
| 265 |
player.haste = Math.max(player.haste, ability.duration); |
| 266 |
break; |
| 267 |
case "rage": |
| 268 |
player.hp = 100; |
| 269 |
player.armour = Math.max(player.armour, 25); |
| 270 |
player.haste = Math.max(player.haste, ability.duration); |
| 271 |
for (const s of buyableSlots) player.charges[s] = Math.max(player.charges[s], agent.abilities[s].charges); |
| 272 |
break; |
| 273 |
case "revealall": |
| 274 |
for (const other of state.players) |
| 275 |
if (other.side !== player.side) other.revealed = Math.max(other.revealed, ability.duration); |
| 276 |
break; |
| 277 |
case "slowall": |
| 278 |
for (const other of state.players) |
| 279 |
if (other.side !== player.side) { |
| 280 |
other.revealed = Math.max(other.revealed, ability.duration); |
| 281 |
other.slow = Math.max(other.slow, ability.duration); |
| 282 |
} |
| 283 |
break; |
| 284 |
} |
| 285 |
|
| 286 |
if (slot === "x") player.ult = 0; |
| 287 |
else { |
| 288 |
player.charges[slot] = Math.max(0, player.charges[slot] - 1); |
| 289 |
if (ability.cooldown > 0) player.cooldown[slot] = ability.cooldown; |
| 290 |
} |
| 291 |
return true; |
| 292 |
} |