fable/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Files 9437cb3d70375fd62d025a6905df31145f578113
| 1 | /** |
| 2 | * The match: one round loop, server-authoritative, and the only place a |
| 3 | * number changes. |
| 4 | * |
| 5 | * `tick(state, dt)` is the whole game — call it with a fixed step and it |
| 6 | * runs without a clock, a socket or a browser, which is how the tests play |
| 7 | * whole rounds in a millisecond. Inputs arrive as intent (a direction, an |
| 8 | * angle, a held key); nothing a client says is taken as a position. |
| 9 | */ |
| 10 | |
| 11 | import { cast, castable, agentById, buyableSlots, flashDuration, sightBlocked, SLOTS } from "./abilities.ts"; |
| 12 | import { award, clampCredits, roundReward, spend, START_CREDITS } from "./economy.ts"; |
| 13 | import { |
| 14 | blockedCircle, |
| 15 | clearPaths, |
| 16 | moveCircle, |
| 17 | orbSpots, |
| 18 | rayHitWall, |
| 19 | segmentPoint, |
| 20 | siteAt, |
| 21 | spawnCells, |
| 22 | type Point, |
| 23 | } from "./map.ts"; |
| 24 | import { emptyInput, type Input, type MatchState, type Player, type Side, type Slot, type Team } from "./state.ts"; |
| 25 | import { applyDamage, damage, shotSpread, SHIELD, WEAPONS, zoneFromMiss, type WeaponId } from "./weapons.ts"; |
| 26 | |
| 27 | export const PLAYER_R = 16; |
| 28 | export const RUN_SPEED = 250; |
| 29 | export const BUY_TIME = 14; |
| 30 | export const ROUND_TIME = 100; |
| 31 | export const PLANT_TIME = 4; |
| 32 | export const FUSE_TIME = 45; |
| 33 | export const DEFUSE_TIME = 7; |
| 34 | export const DEFUSE_CHECKPOINT = 3.5; |
| 35 | export const END_TIME = 5; |
| 36 | export const OVER_TIME = 12; |
| 37 | export const SWAP_AFTER = 12; |
| 38 | export const WIN_ROUNDS = 13; |
| 39 | export const ROSTER = 5; |
| 40 | export const SPIKE_REACH = 46; |
| 41 | |
| 42 | const TEAMS: Team[] = ["red", "blue"]; |
| 43 | |
| 44 | /** Deterministic randomness: the same seed plays the same round. */ |
| 45 | export function random(state: MatchState): number { |
| 46 | state.seed = (state.seed + 0x6d2b79f5) | 0; |
| 47 | let t = state.seed; |
| 48 | t = Math.imul(t ^ (t >>> 15), t | 1); |
| 49 | t ^= t + Math.imul(t ^ (t >>> 7), t | 61); |
| 50 | return ((t ^ (t >>> 14)) >>> 0) / 4294967296; |
| 51 | } |
| 52 | |
| 53 | const say = (state: MatchState, text: string): void => { |
| 54 | state.events.unshift({ text, age: 0 }); |
| 55 | state.events.length = Math.min(state.events.length, 12); |
| 56 | }; |
| 57 | |
| 58 | export const sideOf = (state: MatchState, team: Team): Side => |
| 59 | team === state.attackers ? "atk" : "def"; |
| 60 | |
| 61 | export const teammates = (state: MatchState, side: Side): Player[] => |
| 62 | state.players.filter((p) => p.side === side); |
| 63 | |
| 64 | export const aliveOn = (state: MatchState, side: Side): Player[] => |
| 65 | state.players.filter((p) => p.side === side && p.alive); |
| 66 | |
| 67 | const BOT_NAMES = [ |
| 68 | "Ash", "Bex", "Cade", "Dune", "Enzo", "Fay", "Gus", "Hale", "Ivo", "Juno", |
| 69 | "Kit", "Lux", "Mox", "Nell", "Orla", "Pike", "Quill", "Rio", "Sage", "Tove", |
| 70 | ]; |
| 71 | |
| 72 | export function makePlayer(state: MatchState, team: Team, index: number): Player { |
| 73 | const agent = ["ember", "veil", "sable", "mender", "rook"][index % 5]!; |
| 74 | const name = BOT_NAMES[(TEAMS.indexOf(team) * 7 + index * 3 + Math.floor(random(state) * 20)) % BOT_NAMES.length]!; |
| 75 | return { |
| 76 | id: `bot-${team}-${index}`, |
| 77 | name: `${name}`, |
| 78 | bot: true, |
| 79 | agent, |
| 80 | team, |
| 81 | side: sideOf(state, team), |
| 82 | x: 0, |
| 83 | y: 0, |
| 84 | angle: 0, |
| 85 | hp: 100, |
| 86 | armour: 0, |
| 87 | alive: true, |
| 88 | credits: START_CREDITS, |
| 89 | weapon: "classic", |
| 90 | ammo: WEAPONS.classic.magazine, |
| 91 | reloadIn: 0, |
| 92 | fireIn: 0, |
| 93 | charges: { c: 0, q: 0, e: 1, x: 0 }, |
| 94 | cooldown: { c: 0, q: 0, e: 0, x: 0 }, |
| 95 | ult: 0, |
| 96 | blind: 0, |
| 97 | slow: 0, |
| 98 | haste: 0, |
| 99 | revealed: 0, |
| 100 | spike: false, |
| 101 | planting: 0, |
| 102 | defusing: 0, |
| 103 | kills: 0, |
| 104 | deaths: 0, |
| 105 | assists: 0, |
| 106 | damageDone: 0, |
| 107 | input: emptyInput(), |
| 108 | goal: null, |
| 109 | think: 0, |
| 110 | spray: 0, |
| 111 | joinedAt: 0, |
| 112 | }; |
| 113 | } |
| 114 | |
| 115 | export function newMatch(seed = Date.now() % 100000): MatchState { |
| 116 | const state: MatchState = { |
| 117 | id: `m${seed.toString(36)}${Math.floor(seed / 7) % 997}`, |
| 118 | phase: "buy", |
| 119 | clock: BUY_TIME, |
| 120 | round: 1, |
| 121 | score: { red: 0, blue: 0 }, |
| 122 | attackers: "red", |
| 123 | lossStreak: { red: 0, blue: 0 }, |
| 124 | players: [], |
| 125 | zones: [], |
| 126 | nextZone: 1, |
| 127 | spike: { carried: null, x: 0, y: 0, planted: false, fuse: 0, site: null, defuse: 0, checkpoint: 0 }, |
| 128 | orbs: orbSpots().map((p) => ({ ...p, taken: false })), |
| 129 | shots: [], |
| 130 | events: [], |
| 131 | result: null, |
| 132 | seed, |
| 133 | now: 0, |
| 134 | }; |
| 135 | for (const team of TEAMS) |
| 136 | for (let i = 0; i < ROSTER; i++) state.players.push(makePlayer(state, team, i)); |
| 137 | startRound(state, true); |
| 138 | say(state, "Round 1 — buy phase"); |
| 139 | return state; |
| 140 | } |
| 141 | |
| 142 | /** A fresh match on the same seats: an ended match never parks. */ |
| 143 | export function resetMatch(state: MatchState): void { |
| 144 | const fresh = newMatch((state.seed + 7919) % 100000); |
| 145 | fresh.players = state.players; |
| 146 | fresh.attackers = state.attackers === "red" ? "blue" : "red"; |
| 147 | for (const player of fresh.players) { |
| 148 | player.kills = 0; |
| 149 | player.deaths = 0; |
| 150 | player.assists = 0; |
| 151 | player.damageDone = 0; |
| 152 | player.ult = 0; |
| 153 | player.credits = START_CREDITS; |
| 154 | player.weapon = "classic"; |
| 155 | } |
| 156 | Object.assign(state, fresh); |
| 157 | startRound(state, true); |
| 158 | say(state, "New match — round 1"); |
| 159 | } |
| 160 | |
| 161 | function placeAtSpawn(state: MatchState, player: Player, index: number): void { |
| 162 | const cells = spawnCells(player.side); |
| 163 | const spot = cells[index % cells.length]!; |
| 164 | player.x = spot.x + (random(state) - 0.5) * 20; |
| 165 | player.y = spot.y + (random(state) - 0.5) * 20; |
| 166 | const centre = player.side === "atk" ? { x: 1200, y: 400 } : { x: 1200, y: 1200 }; |
| 167 | player.angle = Math.atan2(centre.y - player.y, centre.x - player.x); |
| 168 | } |
| 169 | |
| 170 | export function startRound(state: MatchState, first = false): void { |
| 171 | clearPaths(); |
| 172 | state.phase = "buy"; |
| 173 | state.clock = BUY_TIME; |
| 174 | state.zones = []; |
| 175 | state.shots = []; |
| 176 | state.spike = { carried: null, x: 0, y: 0, planted: false, fuse: 0, site: null, defuse: 0, checkpoint: 0 }; |
| 177 | state.orbs = orbSpots().map((p) => ({ ...p, taken: false })); |
| 178 | |
| 179 | const byTeam: Record<Team, number> = { red: 0, blue: 0 }; |
| 180 | for (const player of state.players) { |
| 181 | player.side = sideOf(state, player.team); |
| 182 | // A gun survives the round its owner does; armour never does. |
| 183 | if (!player.alive || first) player.weapon = "classic"; |
| 184 | player.armour = 0; |
| 185 | player.hp = 100; |
| 186 | player.alive = true; |
| 187 | player.ammo = WEAPONS[player.weapon].magazine; |
| 188 | player.reloadIn = 0; |
| 189 | player.fireIn = 0; |
| 190 | player.blind = 0; |
| 191 | player.slow = 0; |
| 192 | player.haste = 0; |
| 193 | player.revealed = 0; |
| 194 | player.spike = false; |
| 195 | player.planting = 0; |
| 196 | player.defusing = 0; |
| 197 | player.spray = 0; |
| 198 | player.goal = null; |
| 199 | player.think = 0; |
| 200 | player.input = emptyInput(); |
| 201 | // Basics are bought fresh every round; the signature comes back free. |
| 202 | player.charges = { c: 0, q: 0, e: 1, x: 0 }; |
| 203 | player.cooldown = { c: 0, q: 0, e: 0, x: 0 }; |
| 204 | if (first) player.credits = START_CREDITS; |
| 205 | placeAtSpawn(state, player, byTeam[player.team]++); |
| 206 | } |
| 207 | giveSpike(state); |
| 208 | } |
| 209 | |
| 210 | function giveSpike(state: MatchState): void { |
| 211 | const attackers = aliveOn(state, "atk"); |
| 212 | if (attackers.length === 0) return; |
| 213 | const carrier = attackers[Math.floor(random(state) * attackers.length)]!; |
| 214 | carrier.spike = true; |
| 215 | state.spike.carried = carrier.id; |
| 216 | state.spike.x = carrier.x; |
| 217 | state.spike.y = carrier.y; |
| 218 | } |
| 219 | |
| 220 | /** Buying is refused server-side: no credits, no gun, no argument. */ |
| 221 | export function buyWeapon(state: MatchState, player: Player, id: WeaponId): boolean { |
| 222 | if (state.phase !== "buy") return false; |
| 223 | const cost = WEAPONS[id].cost; |
| 224 | const paid = spend(player.credits, cost); |
| 225 | if (!paid.ok) return false; |
| 226 | player.credits = paid.credits; |
| 227 | player.weapon = id; |
| 228 | player.ammo = WEAPONS[id].magazine; |
| 229 | return true; |
| 230 | } |
| 231 | |
| 232 | export function buyShield(state: MatchState, player: Player, kind: "light" | "heavy"): boolean { |
| 233 | if (state.phase !== "buy") return false; |
| 234 | const shield = SHIELD[kind]; |
| 235 | if (player.armour >= shield.armour) return false; |
| 236 | const paid = spend(player.credits, shield.cost); |
| 237 | if (!paid.ok) return false; |
| 238 | player.credits = paid.credits; |
| 239 | player.armour = shield.armour; |
| 240 | return true; |
| 241 | } |
| 242 | |
| 243 | export function buyAbility(state: MatchState, player: Player, slot: Slot): boolean { |
| 244 | if (state.phase !== "buy" || !buyableSlots.includes(slot)) return false; |
| 245 | const ability = agentById(player.agent).abilities[slot]; |
| 246 | if (player.charges[slot] >= ability.charges) return false; |
| 247 | const paid = spend(player.credits, ability.cost); |
| 248 | if (!paid.ok) return false; |
| 249 | player.credits = paid.credits; |
| 250 | player.charges[slot] += 1; |
| 251 | return true; |
| 252 | } |
| 253 | |
| 254 | export function pickAgent(state: MatchState, player: Player, agent: string): boolean { |
| 255 | if (state.phase !== "buy" || player.charges.c > 0 || player.charges.q > 0) return false; |
| 256 | player.agent = agent; |
| 257 | return true; |
| 258 | } |
| 259 | |
| 260 | // ---------------------------------------------------------------- the tick |
| 261 | |
| 262 | export function tick(state: MatchState, dt: number): void { |
| 263 | state.now += dt; |
| 264 | for (const shot of state.shots) shot.age += dt; |
| 265 | state.shots = state.shots.filter((s) => s.age < 0.12); |
| 266 | for (const event of state.events) event.age += dt; |
| 267 | |
| 268 | if (state.phase === "over") { |
| 269 | state.clock -= dt; |
| 270 | if (state.clock <= 0) resetMatch(state); |
| 271 | return; |
| 272 | } |
| 273 | if (state.phase === "end") { |
| 274 | state.clock -= dt; |
| 275 | if (state.clock <= 0) nextRound(state); |
| 276 | return; |
| 277 | } |
| 278 | |
| 279 | state.clock -= dt; |
| 280 | if (state.phase === "buy") { |
| 281 | for (const player of state.players) movePlayer(state, player, dt, true); |
| 282 | if (state.clock <= 0) { |
| 283 | state.phase = "live"; |
| 284 | state.clock = ROUND_TIME; |
| 285 | say(state, "Round live"); |
| 286 | } |
| 287 | return; |
| 288 | } |
| 289 | |
| 290 | for (const player of state.players) { |
| 291 | if (player.blind > 0) player.blind = Math.max(0, player.blind - dt); |
| 292 | if (player.slow > 0) player.slow = Math.max(0, player.slow - dt); |
| 293 | if (player.haste > 0) player.haste = Math.max(0, player.haste - dt); |
| 294 | if (player.revealed > 0) player.revealed = Math.max(0, player.revealed - dt); |
| 295 | for (const slot of SLOTS) |
| 296 | if (player.cooldown[slot] > 0) { |
| 297 | player.cooldown[slot] = Math.max(0, player.cooldown[slot] - dt); |
| 298 | // The signature charge comes back on its own when the wait is over. |
| 299 | if (player.cooldown[slot] === 0) player.charges[slot] = Math.max(player.charges[slot], 1); |
| 300 | } |
| 301 | player.spray = Math.max(0, player.spray - dt * 6); |
| 302 | if (!player.alive) continue; |
| 303 | movePlayer(state, player, dt, false); |
| 304 | useAbility(state, player); |
| 305 | shoot(state, player, dt); |
| 306 | spikeWork(state, player, dt); |
| 307 | orbs(state, player); |
| 308 | } |
| 309 | |
| 310 | if (state.spike.planted) state.spike.fuse = Math.max(0, state.spike.fuse - dt); |
| 311 | zones(state, dt); |
| 312 | endConditions(state); |
| 313 | } |
| 314 | |
| 315 | function speedOf(player: Player): number { |
| 316 | let speed = RUN_SPEED; |
| 317 | if (player.haste > 0) speed *= 1.35; |
| 318 | if (player.slow > 0) speed *= 0.55; |
| 319 | if (player.planting > 0 || player.defusing > 0) speed = 0; |
| 320 | if (player.weapon === "operator") speed *= 0.86; |
| 321 | return speed; |
| 322 | } |
| 323 | |
| 324 | function movePlayer(state: MatchState, player: Player, dt: number, penned: boolean): void { |
| 325 | if (!player.alive) return; |
| 326 | const input = player.input; |
| 327 | if (Number.isFinite(input.angle)) player.angle = input.angle; |
| 328 | let mx = input.mx; |
| 329 | let my = input.my; |
| 330 | const len = Math.hypot(mx, my); |
| 331 | if (len > 1) { |
| 332 | mx /= len; |
| 333 | my /= len; |
| 334 | } |
| 335 | const speed = speedOf(player) * dt; |
| 336 | if (speed === 0 || (mx === 0 && my === 0)) return; |
| 337 | let next = moveCircle(player.x, player.y, mx * speed, my * speed, PLAYER_R); |
| 338 | // In the buy phase nobody leaves their own spawn. |
| 339 | if (penned) { |
| 340 | const cells = spawnCells(player.side); |
| 341 | const near = cells.reduce((best, c) => (Math.hypot(c.x - next.x, c.y - next.y) < Math.hypot(best.x - next.x, best.y - next.y) ? c : best)); |
| 342 | if (Math.hypot(near.x - next.x, near.y - next.y) > 120) return; |
| 343 | } |
| 344 | // Walls put down by abilities are as solid as the map's. |
| 345 | for (const zone of state.zones) { |
| 346 | if (!zone.solid || zone.fuse > 0) continue; |
| 347 | const gap = Math.hypot(next.x - zone.x, next.y - zone.y); |
| 348 | if (gap < zone.r + PLAYER_R) { |
| 349 | const push = zone.r + PLAYER_R - gap; |
| 350 | const angle = gap === 0 ? 0 : Math.atan2(next.y - zone.y, next.x - zone.x); |
| 351 | const shoved = { x: next.x + Math.cos(angle) * push, y: next.y + Math.sin(angle) * push }; |
| 352 | if (!blockedCircle(shoved.x, shoved.y, PLAYER_R)) next = shoved; |
| 353 | else next = { x: player.x, y: player.y }; |
| 354 | } |
| 355 | } |
| 356 | player.x = next.x; |
| 357 | player.y = next.y; |
| 358 | } |
| 359 | |
| 360 | function useAbility(state: MatchState, player: Player): void { |
| 361 | const slot = player.input.cast; |
| 362 | if (slot === null) return; |
| 363 | player.input.cast = null; |
| 364 | if (state.phase !== "live" || player.blind > 0.8) return; |
| 365 | if (cast(state, player, slot)) { |
| 366 | const ability = agentById(player.agent).abilities[slot]; |
| 367 | if (slot === "x") say(state, `${player.name} used ${ability.name}`); |
| 368 | } |
| 369 | } |
| 370 | |
| 371 | function shoot(state: MatchState, player: Player, dt: number): void { |
| 372 | player.fireIn = Math.max(0, player.fireIn - dt); |
| 373 | if (player.reloadIn > 0) { |
| 374 | player.reloadIn -= dt; |
| 375 | if (player.reloadIn <= 0) { |
| 376 | player.ammo = WEAPONS[player.weapon].magazine; |
| 377 | player.reloadIn = 0; |
| 378 | } |
| 379 | return; |
| 380 | } |
| 381 | const weapon = WEAPONS[player.weapon]; |
| 382 | if (player.input.reload && player.ammo < weapon.magazine) { |
| 383 | player.input.reload = false; |
| 384 | player.reloadIn = weapon.reload; |
| 385 | return; |
| 386 | } |
| 387 | if (!player.input.fire || player.fireIn > 0) return; |
| 388 | if (!weapon.auto) player.input.fire = false; |
| 389 | if (player.ammo <= 0) { |
| 390 | player.reloadIn = weapon.reload; |
| 391 | return; |
| 392 | } |
| 393 | player.ammo -= 1; |
| 394 | player.fireIn = 1 / weapon.rate; |
| 395 | player.spray = Math.min(6, player.spray + 1); |
| 396 | |
| 397 | const moving = Math.hypot(player.input.mx, player.input.my) > 0.1 ? 1 : 0; |
| 398 | // Blind aim is a guess: the shot goes somewhere near where you were looking. |
| 399 | const wobble = player.blind > 0 ? 0.5 : 0; |
| 400 | const spread = shotSpread(player.weapon, moving) + player.spray * 0.006 + wobble; |
| 401 | const angle = player.angle + (random(state) - 0.5) * 2 * spread; |
| 402 | const from: Point = { x: player.x, y: player.y }; |
| 403 | const wall = rayHitWall(from, angle, weapon.range); |
| 404 | let stop = wall; |
| 405 | // A wall an ability put up stops bullets too; smoke does not. |
| 406 | for (const zone of state.zones) { |
| 407 | if (!zone.solid || zone.fuse > 0) continue; |
| 408 | const near = segmentPoint(from, wall, zone); |
| 409 | if (near.dist <= zone.r && near.travel < Math.hypot(stop.x - from.x, stop.y - from.y)) |
| 410 | stop = { x: from.x + Math.cos(angle) * near.travel, y: from.y + Math.sin(angle) * near.travel }; |
| 411 | } |
| 412 | |
| 413 | let best: { target: Player; travel: number; miss: number } | null = null; |
| 414 | for (const other of state.players) { |
| 415 | if (!other.alive || other.side === player.side || other.id === player.id) continue; |
| 416 | const near = segmentPoint(from, stop, other); |
| 417 | if (near.dist > PLAYER_R || near.travel < 1) continue; |
| 418 | if (best === null || near.travel < best.travel) best = { target: other, travel: near.travel, miss: near.dist }; |
| 419 | } |
| 420 | |
| 421 | const end = best ? { x: from.x + Math.cos(angle) * best.travel, y: from.y + Math.sin(angle) * best.travel } : stop; |
| 422 | state.shots.push({ x1: from.x, y1: from.y, x2: end.x, y2: end.y, hit: best !== null, age: 0 }); |
| 423 | if (!best) return; |
| 424 | |
| 425 | const zone = zoneFromMiss(best.miss, PLAYER_R); |
| 426 | const dealt = damage(player.weapon, zone, best.travel); |
| 427 | hurt(state, best.target, dealt, player, zone === "head" ? "headshot" : ""); |
| 428 | } |
| 429 | |
| 430 | function hurt(state: MatchState, target: Player, amount: number, from: Player | null, note: string): void { |
| 431 | if (!target.alive) return; |
| 432 | const before = target.hp + target.armour; |
| 433 | const after = applyDamage(target.hp, target.armour, amount); |
| 434 | target.hp = after.hp; |
| 435 | target.armour = after.armour; |
| 436 | if (from) from.damageDone += Math.max(0, before - (target.hp + target.armour)); |
| 437 | if (target.hp > 0) return; |
| 438 | target.alive = false; |
| 439 | target.deaths += 1; |
| 440 | target.planting = 0; |
| 441 | // A defuse dies with the defender, back to the half-way checkpoint. |
| 442 | if (target.defusing > 0) { |
| 443 | target.defusing = 0; |
| 444 | state.spike.defuse = state.spike.checkpoint; |
| 445 | } |
| 446 | if (target.spike) { |
| 447 | target.spike = false; |
| 448 | state.spike.carried = null; |
| 449 | state.spike.x = target.x; |
| 450 | state.spike.y = target.y; |
| 451 | } |
| 452 | if (from && from.side !== target.side) { |
| 453 | from.kills += 1; |
| 454 | from.credits = award(from.credits, 200); |
| 455 | from.ult = Math.min(10, from.ult + 1); |
| 456 | say(state, `${from.name} ${note === "headshot" ? "headshot" : "killed"} ${target.name}`); |
| 457 | } else { |
| 458 | say(state, `${target.name} died`); |
| 459 | } |
| 460 | } |
| 461 | |
| 462 | function spikeWork(state: MatchState, player: Player, dt: number): void { |
| 463 | const spike = state.spike; |
| 464 | if (player.side === "atk" && !spike.planted) { |
| 465 | if (!spike.carried && !player.spike && Math.hypot(player.x - spike.x, player.y - spike.y) < SPIKE_REACH) { |
| 466 | player.spike = true; |
| 467 | spike.carried = player.id; |
| 468 | say(state, `${player.name} picked up the spike`); |
| 469 | } |
| 470 | if (player.spike) { |
| 471 | spike.x = player.x; |
| 472 | spike.y = player.y; |
| 473 | const site = siteAt(player.x, player.y); |
| 474 | if (player.input.use && site) { |
| 475 | player.planting += dt; |
| 476 | if (player.planting >= PLANT_TIME) { |
| 477 | spike.planted = true; |
| 478 | spike.site = site; |
| 479 | spike.fuse = FUSE_TIME; |
| 480 | spike.carried = null; |
| 481 | player.spike = false; |
| 482 | player.planting = 0; |
| 483 | player.ult = Math.min(10, player.ult + 1); |
| 484 | for (const mate of teammates(state, "atk")) mate.credits = award(mate.credits, 300); |
| 485 | say(state, `${player.name} planted on ${site}`); |
| 486 | } |
| 487 | } else player.planting = 0; |
| 488 | } |
| 489 | return; |
| 490 | } |
| 491 | if (player.side === "def" && spike.planted) { |
| 492 | const close = Math.hypot(player.x - spike.x, player.y - spike.y) < SPIKE_REACH; |
| 493 | if (player.input.use && close) { |
| 494 | spike.defuse += dt; |
| 495 | player.defusing = spike.defuse; |
| 496 | // Half way is a checkpoint: the next defender starts from there. |
| 497 | if (spike.defuse >= DEFUSE_CHECKPOINT && spike.checkpoint === 0) { |
| 498 | spike.checkpoint = DEFUSE_CHECKPOINT; |
| 499 | say(state, `${player.name} is half way through the defuse`); |
| 500 | } |
| 501 | } else if (player.defusing > 0) { |
| 502 | player.defusing = 0; |
| 503 | spike.defuse = spike.checkpoint; |
| 504 | } |
| 505 | } |
| 506 | } |
| 507 | |
| 508 | function orbs(state: MatchState, player: Player): void { |
| 509 | for (const orb of state.orbs) |
| 510 | if (!orb.taken && Math.hypot(player.x - orb.x, player.y - orb.y) < 40) { |
| 511 | orb.taken = true; |
| 512 | player.ult = Math.min(10, player.ult + 1); |
| 513 | say(state, `${player.name} took an ult orb`); |
| 514 | } |
| 515 | } |
| 516 | |
| 517 | function zones(state: MatchState, dt: number): void { |
| 518 | for (const zone of state.zones) { |
| 519 | if (zone.fuse > 0) { |
| 520 | zone.fuse -= dt; |
| 521 | if (zone.fuse > 0) continue; |
| 522 | if (zone.kind === "flash") { |
| 523 | // Pop: everyone with a sight line to it, blinded by how far their |
| 524 | // back was turned. Smoke between them and it saves them. |
| 525 | for (const player of state.players) { |
| 526 | if (!player.alive) continue; |
| 527 | const blocked = sightBlocked(zone, player, state.zones); |
| 528 | const within = Math.hypot(player.x - zone.x, player.y - zone.y) <= zone.r; |
| 529 | const blind = within ? flashDuration(zone.damage, player, zone, player.angle, blocked) : 0; |
| 530 | if (blind > 0) player.blind = Math.max(player.blind, blind); |
| 531 | } |
| 532 | } |
| 533 | continue; |
| 534 | } |
| 535 | zone.life -= dt; |
| 536 | zone.beat -= dt; |
| 537 | if (zone.kind === "molly" && zone.beat <= 0) { |
| 538 | zone.beat = 0.5; |
| 539 | const owner = state.players.find((p) => p.id === zone.owner) ?? null; |
| 540 | for (const player of state.players) |
| 541 | if (player.alive && player.side !== zone.side && Math.hypot(player.x - zone.x, player.y - zone.y) <= zone.r) |
| 542 | hurt(state, player, Math.round(zone.damage / 2), owner, ""); |
| 543 | } |
| 544 | if (zone.kind === "dart" && zone.beat <= 0 && zone.pulses > 0) { |
| 545 | zone.beat = 1.6; |
| 546 | zone.pulses -= 1; |
| 547 | for (const player of state.players) |
| 548 | if (player.alive && player.side !== zone.side && Math.hypot(player.x - zone.x, player.y - zone.y) <= zone.r && !sightBlocked(zone, player, state.zones)) |
| 549 | player.revealed = Math.max(player.revealed, 2); |
| 550 | } |
| 551 | if (zone.kind === "heal" && zone.beat <= 0) { |
| 552 | zone.beat = 0.5; |
| 553 | const target = state.players.find((p) => p.id === zone.owner); |
| 554 | if (target && target.alive) { |
| 555 | target.hp = Math.min(100, target.hp + zone.damage / 6); |
| 556 | zone.x = target.x; |
| 557 | zone.y = target.y; |
| 558 | } |
| 559 | } |
| 560 | if (zone.kind === "trip" && zone.life > 0) { |
| 561 | const caught = state.players.find( |
| 562 | (p) => p.alive && p.side !== zone.side && Math.hypot(p.x - zone.x, p.y - zone.y) <= zone.r, |
| 563 | ); |
| 564 | if (caught) { |
| 565 | caught.revealed = Math.max(caught.revealed, 4); |
| 566 | caught.slow = Math.max(caught.slow, 3); |
| 567 | zone.life = 0; |
| 568 | say(state, `${caught.name} tripped a wire`); |
| 569 | } |
| 570 | } |
| 571 | // Blackout: a smoke that carries a slow (marked with negative damage). |
| 572 | if (zone.kind === "smoke" && zone.damage < 0) |
| 573 | for (const player of state.players) |
| 574 | if (player.alive && player.side !== zone.side && Math.hypot(player.x - zone.x, player.y - zone.y) <= zone.r) |
| 575 | player.slow = Math.max(player.slow, 0.5); |
| 576 | } |
| 577 | state.zones = state.zones.filter((z) => z.life > 0 || z.fuse > 0); |
| 578 | } |
| 579 | |
| 580 | function endConditions(state: MatchState): void { |
| 581 | const spike = state.spike; |
| 582 | if (spike.planted && spike.defuse >= DEFUSE_TIME) return finishRound(state, "def", "Spike defused"); |
| 583 | if (aliveOn(state, "def").length === 0) return finishRound(state, "atk", "Defenders eliminated"); |
| 584 | if (aliveOn(state, "atk").length === 0 && !spike.planted) |
| 585 | return finishRound(state, "def", "Attackers eliminated"); |
| 586 | if (spike.planted) { |
| 587 | if (spike.fuse <= 0) return finishRound(state, "atk", "Spike detonated"); |
| 588 | } else if (state.clock <= 0) return finishRound(state, "def", "Time out"); |
| 589 | } |
| 590 | |
| 591 | function finishRound(state: MatchState, winners: Side, why: string): void { |
| 592 | const winningTeam: Team = winners === "atk" ? state.attackers : state.attackers === "red" ? "blue" : "red"; |
| 593 | const losingTeam: Team = winningTeam === "red" ? "blue" : "red"; |
| 594 | state.score[winningTeam] += 1; |
| 595 | state.lossStreak[losingTeam] += 1; |
| 596 | state.lossStreak[winningTeam] = 0; |
| 597 | for (const player of state.players) { |
| 598 | const won = player.team === winningTeam; |
| 599 | player.credits = clampCredits( |
| 600 | player.credits + roundReward(won, state.lossStreak[player.team], state.spike.planted && player.side === "atk"), |
| 601 | ); |
| 602 | } |
| 603 | state.phase = "end"; |
| 604 | state.clock = END_TIME; |
| 605 | say(state, `${why} — ${winningTeam} take round ${state.round} (${state.score.red}–${state.score.blue})`); |
| 606 | if (state.score[winningTeam] >= WIN_ROUNDS) { |
| 607 | state.phase = "over"; |
| 608 | state.clock = OVER_TIME; |
| 609 | state.result = `${winningTeam} win ${state.score.red}–${state.score.blue}`; |
| 610 | say(state, `Match over — ${state.result}. A new one starts in ${OVER_TIME} seconds.`); |
| 611 | } |
| 612 | } |
| 613 | |
| 614 | function nextRound(state: MatchState): void { |
| 615 | state.round += 1; |
| 616 | // Sides swap once twelve rounds are behind us. |
| 617 | if (state.round === SWAP_AFTER + 1) { |
| 618 | state.attackers = state.attackers === "red" ? "blue" : "red"; |
| 619 | say(state, "Sides swap"); |
| 620 | } |
| 621 | startRound(state); |
| 622 | say(state, `Round ${state.round} — buy phase`); |
| 623 | } |
| 624 | |
| 625 | /** What one player is allowed to see: their own side, and what light reaches. */ |
| 626 | export function visible(state: MatchState, viewer: Player, other: Player): boolean { |
| 627 | if (other.id === viewer.id || other.side === viewer.side) return true; |
| 628 | if (!other.alive) return false; |
| 629 | if (other.revealed > 0) return true; |
| 630 | if (viewer.blind > 0) return false; |
| 631 | // A dead player watches the round out through their team's eyes, not |
| 632 | // through the walls: what a living team mate can see, and nothing more. |
| 633 | // Otherwise dying and staying connected would be a wallhack to call out. |
| 634 | if (!viewer.alive) |
| 635 | return state.players.some( |
| 636 | (mate) => mate.side === viewer.side && mate.alive && mate.blind <= 0 && !sightBlocked(mate, other, state.zones), |
| 637 | ); |
| 638 | return !sightBlocked(viewer, other, state.zones); |
| 639 | } |
| 640 | |
| 641 | export function setInput(player: Player, input: Partial<Input>): void { |
| 642 | const target = player.input; |
| 643 | if (typeof input.mx === "number" && Number.isFinite(input.mx)) target.mx = Math.max(-1, Math.min(1, input.mx)); |
| 644 | if (typeof input.my === "number" && Number.isFinite(input.my)) target.my = Math.max(-1, Math.min(1, input.my)); |
| 645 | if (typeof input.angle === "number" && Number.isFinite(input.angle)) target.angle = input.angle; |
| 646 | if (typeof input.fire === "boolean") target.fire = input.fire; |
| 647 | if (typeof input.use === "boolean") target.use = input.use; |
| 648 | if (typeof input.reload === "boolean") target.reload = input.reload; |
| 649 | if (input.cast === "c" || input.cast === "q" || input.cast === "e" || input.cast === "x") target.cast = input.cast; |
| 650 | } |
| 651 | |
| 652 | export { castable, agentById }; |