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