| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
import { cast, castable, agentById, buyableSlots, flashDuration, sightBlocked, SLOTS } from "./abilities.ts"; |
| 12 |
import { emptyTrack } from "./engine/aim.ts"; |
| 13 |
import { BODY_R, moveSpeed, RUN } from "./engine/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 |
|
| 30 |
|
| 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 |
|
| 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 |
|
| 78 |
|
| 79 |
|
| 80 |
|
| 81 |
|
| 82 |
|
| 83 |
|
| 84 |
|
| 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 |
|
| 99 |
|
| 100 |
|
| 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 |
flashedAt: -99, |
| 146 |
spray: 0, |
| 147 |
joinedAt: 0, |
| 148 |
}; |
| 149 |
} |
| 150 |
|
| 151 |
export function newMatch(seed = Date.now() % 100000): MatchState { |
| 152 |
const state: MatchState = { |
| 153 |
id: `m${seed.toString(36)}${Math.floor(seed / 7) % 997}`, |
| 154 |
phase: "buy", |
| 155 |
clock: BUY_TIME, |
| 156 |
round: 1, |
| 157 |
score: { red: 0, blue: 0 }, |
| 158 |
attackers: "red", |
| 159 |
lossStreak: { red: 0, blue: 0 }, |
| 160 |
players: [], |
| 161 |
zones: [], |
| 162 |
nextZone: 1, |
| 163 |
spike: { carried: null, x: 0, y: 0, planted: false, fuse: 0, site: null, defuse: 0, checkpoint: 0 }, |
| 164 |
orbs: orbSpots().map((p) => ({ ...p, taken: false })), |
| 165 |
shots: [], |
| 166 |
events: [], |
| 167 |
result: null, |
| 168 |
last: null, |
| 169 |
seed, |
| 170 |
now: 0, |
| 171 |
}; |
| 172 |
const names = dealNames(state); |
| 173 |
for (const team of TEAMS) |
| 174 |
for (let i = 0; i < ROSTER; i++) |
| 175 |
state.players.push(makePlayer(state, team, i, names[state.players.length])); |
| 176 |
startRound(state, true); |
| 177 |
say(state, "Round 1 — buy phase"); |
| 178 |
return state; |
| 179 |
} |
| 180 |
|
| 181 |
|
| 182 |
export function resetMatch(state: MatchState): void { |
| 183 |
const fresh = newMatch((state.seed + 7919) % 100000); |
| 184 |
fresh.players = state.players; |
| 185 |
fresh.attackers = state.attackers === "red" ? "blue" : "red"; |
| 186 |
for (const player of fresh.players) { |
| 187 |
player.kills = 0; |
| 188 |
player.deaths = 0; |
| 189 |
player.assists = 0; |
| 190 |
player.damageDone = 0; |
| 191 |
player.ult = 0; |
| 192 |
player.credits = START_CREDITS; |
| 193 |
player.weapon = "classic"; |
| 194 |
} |
| 195 |
Object.assign(state, fresh); |
| 196 |
startRound(state, true); |
| 197 |
say(state, "New match — round 1"); |
| 198 |
} |
| 199 |
|
| 200 |
function placeAtSpawn(state: MatchState, player: Player, index: number): void { |
| 201 |
const cells = spawnCells(player.side); |
| 202 |
const spot = cells[index % cells.length]!; |
| 203 |
player.x = spot.x + (random(state) - 0.5) * 20; |
| 204 |
player.y = spot.y + (random(state) - 0.5) * 20; |
| 205 |
const centre = player.side === "atk" ? { x: 1200, y: 400 } : { x: 1200, y: 1200 }; |
| 206 |
player.angle = Math.atan2(centre.y - player.y, centre.x - player.x); |
| 207 |
} |
| 208 |
|
| 209 |
export function startRound(state: MatchState, first = false): void { |
| 210 |
clearPaths(); |
| 211 |
state.phase = "buy"; |
| 212 |
state.clock = BUY_TIME; |
| 213 |
state.zones = []; |
| 214 |
state.shots = []; |
| 215 |
state.spike = { carried: null, x: 0, y: 0, planted: false, fuse: 0, site: null, defuse: 0, checkpoint: 0 }; |
| 216 |
state.orbs = orbSpots().map((p) => ({ ...p, taken: false })); |
| 217 |
|
| 218 |
const byTeam: Record<Team, number> = { red: 0, blue: 0 }; |
| 219 |
for (const player of state.players) { |
| 220 |
player.side = sideOf(state, player.team); |
| 221 |
|
| 222 |
if (!player.alive || first) player.weapon = "classic"; |
| 223 |
player.armour = 0; |
| 224 |
player.hp = 100; |
| 225 |
player.alive = true; |
| 226 |
player.ammo = WEAPONS[player.weapon].magazine; |
| 227 |
player.reloadIn = 0; |
| 228 |
player.fireIn = 0; |
| 229 |
player.blind = 0; |
| 230 |
player.slow = 0; |
| 231 |
player.haste = 0; |
| 232 |
player.revealed = 0; |
| 233 |
player.spike = false; |
| 234 |
player.planting = 0; |
| 235 |
player.defusing = 0; |
| 236 |
player.spray = 0; |
| 237 |
player.goal = null; |
| 238 |
player.think = 0; |
| 239 |
player.aim = emptyTrack(); |
| 240 |
|
| 241 |
player.flashedAt = -99; |
| 242 |
|
| 243 |
player.charges = { c: 0, q: 0, e: 1, x: 0 }; |
| 244 |
player.cooldown = { c: 0, q: 0, e: 0, x: 0 }; |
| 245 |
if (first) player.credits = START_CREDITS; |
| 246 |
placeAtSpawn(state, player, byTeam[player.team]++); |
| 247 |
|
| 248 |
|
| 249 |
|
| 250 |
|
| 251 |
|
| 252 |
player.input = { ...emptyInput(), angle: player.angle }; |
| 253 |
} |
| 254 |
giveSpike(state); |
| 255 |
} |
| 256 |
|
| 257 |
function giveSpike(state: MatchState): void { |
| 258 |
const attackers = aliveOn(state, "atk"); |
| 259 |
if (attackers.length === 0) return; |
| 260 |
const carrier = attackers[Math.floor(random(state) * attackers.length)]!; |
| 261 |
carrier.spike = true; |
| 262 |
state.spike.carried = carrier.id; |
| 263 |
state.spike.x = carrier.x; |
| 264 |
state.spike.y = carrier.y; |
| 265 |
} |
| 266 |
|
| 267 |
|
| 268 |
export function buyWeapon(state: MatchState, player: Player, id: WeaponId): boolean { |
| 269 |
if (state.phase !== "buy") return false; |
| 270 |
const cost = WEAPONS[id].cost; |
| 271 |
const paid = spend(player.credits, cost); |
| 272 |
if (!paid.ok) return false; |
| 273 |
player.credits = paid.credits; |
| 274 |
player.weapon = id; |
| 275 |
player.ammo = WEAPONS[id].magazine; |
| 276 |
return true; |
| 277 |
} |
| 278 |
|
| 279 |
export function buyShield(state: MatchState, player: Player, kind: "light" | "heavy"): boolean { |
| 280 |
if (state.phase !== "buy") return false; |
| 281 |
const shield = SHIELD[kind]; |
| 282 |
if (player.armour >= shield.armour) return false; |
| 283 |
const paid = spend(player.credits, shield.cost); |
| 284 |
if (!paid.ok) return false; |
| 285 |
player.credits = paid.credits; |
| 286 |
player.armour = shield.armour; |
| 287 |
return true; |
| 288 |
} |
| 289 |
|
| 290 |
export function buyAbility(state: MatchState, player: Player, slot: Slot): boolean { |
| 291 |
if (state.phase !== "buy" || !buyableSlots.includes(slot)) return false; |
| 292 |
const ability = agentById(player.agent).abilities[slot]; |
| 293 |
if (player.charges[slot] >= ability.charges) return false; |
| 294 |
const paid = spend(player.credits, ability.cost); |
| 295 |
if (!paid.ok) return false; |
| 296 |
player.credits = paid.credits; |
| 297 |
player.charges[slot] += 1; |
| 298 |
return true; |
| 299 |
} |
| 300 |
|
| 301 |
export function pickAgent(state: MatchState, player: Player, agent: string): boolean { |
| 302 |
if (state.phase !== "buy" || player.charges.c > 0 || player.charges.q > 0) return false; |
| 303 |
player.agent = agent; |
| 304 |
return true; |
| 305 |
} |
| 306 |
|
| 307 |
|
| 308 |
|
| 309 |
export function tick(state: MatchState, dt: number): void { |
| 310 |
state.now += dt; |
| 311 |
for (const shot of state.shots) shot.age += dt; |
| 312 |
state.shots = state.shots.filter((s) => s.age < 0.12); |
| 313 |
for (const event of state.events) event.age += dt; |
| 314 |
|
| 315 |
if (state.phase === "over") { |
| 316 |
state.clock -= dt; |
| 317 |
if (state.clock <= 0) resetMatch(state); |
| 318 |
return; |
| 319 |
} |
| 320 |
if (state.phase === "end") { |
| 321 |
state.clock -= dt; |
| 322 |
if (state.clock <= 0) nextRound(state); |
| 323 |
return; |
| 324 |
} |
| 325 |
|
| 326 |
state.clock -= dt; |
| 327 |
if (state.phase === "buy") { |
| 328 |
for (const player of state.players) movePlayer(state, player, dt, true); |
| 329 |
if (state.clock <= 0) { |
| 330 |
state.phase = "live"; |
| 331 |
state.clock = ROUND_TIME; |
| 332 |
say(state, "Round live"); |
| 333 |
} |
| 334 |
return; |
| 335 |
} |
| 336 |
|
| 337 |
for (const player of state.players) { |
| 338 |
if (player.blind > 0) player.blind = Math.max(0, player.blind - dt); |
| 339 |
if (player.slow > 0) player.slow = Math.max(0, player.slow - dt); |
| 340 |
if (player.haste > 0) player.haste = Math.max(0, player.haste - dt); |
| 341 |
if (player.revealed > 0) player.revealed = Math.max(0, player.revealed - dt); |
| 342 |
for (const slot of SLOTS) |
| 343 |
if (player.cooldown[slot] > 0) { |
| 344 |
player.cooldown[slot] = Math.max(0, player.cooldown[slot] - dt); |
| 345 |
|
| 346 |
if (player.cooldown[slot] === 0) player.charges[slot] = Math.max(player.charges[slot], 1); |
| 347 |
} |
| 348 |
player.spray = Math.max(0, player.spray - dt * 6); |
| 349 |
if (!player.alive) continue; |
| 350 |
movePlayer(state, player, dt, false); |
| 351 |
useAbility(state, player); |
| 352 |
shoot(state, player, dt); |
| 353 |
spikeWork(state, player, dt); |
| 354 |
orbs(state, player); |
| 355 |
} |
| 356 |
|
| 357 |
if (state.spike.planted) state.spike.fuse = Math.max(0, state.spike.fuse - dt); |
| 358 |
zones(state, dt); |
| 359 |
endConditions(state); |
| 360 |
} |
| 361 |
|
| 362 |
function speedOf(player: Player): number { |
| 363 |
return moveSpeed({ |
| 364 |
haste: player.haste > 0, |
| 365 |
slow: player.slow > 0, |
| 366 |
stopped: player.planting > 0 || player.defusing > 0, |
| 367 |
heavy: player.weapon === "operator", |
| 368 |
}); |
| 369 |
} |
| 370 |
|
| 371 |
function movePlayer(state: MatchState, player: Player, dt: number, penned: boolean): void { |
| 372 |
if (!player.alive) return; |
| 373 |
const input = player.input; |
| 374 |
if (Number.isFinite(input.angle)) player.angle = input.angle; |
| 375 |
let mx = input.mx; |
| 376 |
let my = input.my; |
| 377 |
const len = Math.hypot(mx, my); |
| 378 |
if (len > 1) { |
| 379 |
mx /= len; |
| 380 |
my /= len; |
| 381 |
} |
| 382 |
const speed = speedOf(player) * dt; |
| 383 |
if (speed === 0 || (mx === 0 && my === 0)) return; |
| 384 |
let next = moveCircle(player.x, player.y, mx * speed, my * speed, PLAYER_R); |
| 385 |
|
| 386 |
if (penned) { |
| 387 |
const cells = spawnCells(player.side); |
| 388 |
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)); |
| 389 |
if (Math.hypot(near.x - next.x, near.y - next.y) > 120) return; |
| 390 |
} |
| 391 |
|
| 392 |
for (const zone of state.zones) { |
| 393 |
if (!zone.solid || zone.fuse > 0) continue; |
| 394 |
const gap = Math.hypot(next.x - zone.x, next.y - zone.y); |
| 395 |
if (gap < zone.r + PLAYER_R) { |
| 396 |
const push = zone.r + PLAYER_R - gap; |
| 397 |
const angle = gap === 0 ? 0 : Math.atan2(next.y - zone.y, next.x - zone.x); |
| 398 |
const shoved = { x: next.x + Math.cos(angle) * push, y: next.y + Math.sin(angle) * push }; |
| 399 |
if (!blockedCircle(shoved.x, shoved.y, PLAYER_R)) next = shoved; |
| 400 |
else next = { x: player.x, y: player.y }; |
| 401 |
} |
| 402 |
} |
| 403 |
player.x = next.x; |
| 404 |
player.y = next.y; |
| 405 |
} |
| 406 |
|
| 407 |
function useAbility(state: MatchState, player: Player): void { |
| 408 |
const slot = player.input.cast; |
| 409 |
if (slot === null) return; |
| 410 |
player.input.cast = null; |
| 411 |
if (state.phase !== "live" || player.blind > 0.8) return; |
| 412 |
if (cast(state, player, slot)) { |
| 413 |
const ability = agentById(player.agent).abilities[slot]; |
| 414 |
if (slot === "x") say(state, `${player.name} used ${ability.name}`); |
| 415 |
} |
| 416 |
} |
| 417 |
|
| 418 |
function shoot(state: MatchState, player: Player, dt: number): void { |
| 419 |
player.fireIn = Math.max(0, player.fireIn - dt); |
| 420 |
if (player.reloadIn > 0) { |
| 421 |
player.reloadIn -= dt; |
| 422 |
if (player.reloadIn <= 0) { |
| 423 |
player.ammo = WEAPONS[player.weapon].magazine; |
| 424 |
player.reloadIn = 0; |
| 425 |
} |
| 426 |
return; |
| 427 |
} |
| 428 |
const weapon = WEAPONS[player.weapon]; |
| 429 |
if (player.input.reload && player.ammo < weapon.magazine) { |
| 430 |
player.input.reload = false; |
| 431 |
player.reloadIn = weapon.reload; |
| 432 |
return; |
| 433 |
} |
| 434 |
if (!player.input.fire || player.fireIn > 0) return; |
| 435 |
if (!weapon.auto) player.input.fire = false; |
| 436 |
if (player.ammo <= 0) { |
| 437 |
player.reloadIn = weapon.reload; |
| 438 |
return; |
| 439 |
} |
| 440 |
player.ammo -= 1; |
| 441 |
player.fireIn = 1 / weapon.rate; |
| 442 |
player.spray = Math.min(6, player.spray + 1); |
| 443 |
|
| 444 |
const moving = Math.hypot(player.input.mx, player.input.my) > 0.1 ? 1 : 0; |
| 445 |
|
| 446 |
const wobble = player.blind > 0 ? 0.5 : 0; |
| 447 |
const spread = shotSpread(player.weapon, moving) + player.spray * 0.006 + wobble; |
| 448 |
const angle = player.angle + (random(state) - 0.5) * 2 * spread; |
| 449 |
const from: Point = { x: player.x, y: player.y }; |
| 450 |
const wall = rayHitWall(from, angle, weapon.range); |
| 451 |
let stop = wall; |
| 452 |
|
| 453 |
for (const zone of state.zones) { |
| 454 |
if (!zone.solid || zone.fuse > 0) continue; |
| 455 |
const near = segmentPoint(from, wall, zone); |
| 456 |
if (near.dist <= zone.r && near.travel < Math.hypot(stop.x - from.x, stop.y - from.y)) |
| 457 |
stop = { x: from.x + Math.cos(angle) * near.travel, y: from.y + Math.sin(angle) * near.travel }; |
| 458 |
} |
| 459 |
|
| 460 |
let best: { target: Player; travel: number; miss: number } | null = null; |
| 461 |
for (const other of state.players) { |
| 462 |
if (!other.alive || other.side === player.side || other.id === player.id) continue; |
| 463 |
const near = segmentPoint(from, stop, other); |
| 464 |
if (near.dist > PLAYER_R || near.travel < 1) continue; |
| 465 |
if (best === null || near.travel < best.travel) best = { target: other, travel: near.travel, miss: near.dist }; |
| 466 |
} |
| 467 |
|
| 468 |
const end = best ? { x: from.x + Math.cos(angle) * best.travel, y: from.y + Math.sin(angle) * best.travel } : stop; |
| 469 |
state.shots.push({ x1: from.x, y1: from.y, x2: end.x, y2: end.y, hit: best !== null, age: 0 }); |
| 470 |
if (!best) return; |
| 471 |
|
| 472 |
const zone = zoneFromMiss(best.miss, PLAYER_R); |
| 473 |
const dealt = damage(player.weapon, zone, best.travel); |
| 474 |
hurt(state, best.target, dealt, player, zone === "head" ? "headshot" : ""); |
| 475 |
} |
| 476 |
|
| 477 |
function hurt(state: MatchState, target: Player, amount: number, from: Player | null, note: string): void { |
| 478 |
if (!target.alive) return; |
| 479 |
const before = target.hp + target.armour; |
| 480 |
const after = applyDamage(target.hp, target.armour, amount); |
| 481 |
target.hp = after.hp; |
| 482 |
target.armour = after.armour; |
| 483 |
if (from) from.damageDone += Math.max(0, before - (target.hp + target.armour)); |
| 484 |
if (target.hp > 0) return; |
| 485 |
target.alive = false; |
| 486 |
target.deaths += 1; |
| 487 |
target.planting = 0; |
| 488 |
|
| 489 |
if (target.defusing > 0) { |
| 490 |
target.defusing = 0; |
| 491 |
state.spike.defuse = state.spike.checkpoint; |
| 492 |
} |
| 493 |
if (target.spike) { |
| 494 |
target.spike = false; |
| 495 |
state.spike.carried = null; |
| 496 |
state.spike.x = target.x; |
| 497 |
state.spike.y = target.y; |
| 498 |
} |
| 499 |
if (from && from.side !== target.side) { |
| 500 |
from.kills += 1; |
| 501 |
from.credits = award(from.credits, 200); |
| 502 |
from.ult = Math.min(10, from.ult + 1); |
| 503 |
say(state, `${from.name} ${note === "headshot" ? "headshot" : "killed"} ${target.name}`); |
| 504 |
} else { |
| 505 |
say(state, `${target.name} died`); |
| 506 |
} |
| 507 |
} |
| 508 |
|
| 509 |
function spikeWork(state: MatchState, player: Player, dt: number): void { |
| 510 |
const spike = state.spike; |
| 511 |
if (player.side === "atk" && !spike.planted) { |
| 512 |
if (!spike.carried && !player.spike && Math.hypot(player.x - spike.x, player.y - spike.y) < SPIKE_REACH) { |
| 513 |
player.spike = true; |
| 514 |
spike.carried = player.id; |
| 515 |
say(state, `${player.name} picked up the spike`); |
| 516 |
} |
| 517 |
if (player.spike) { |
| 518 |
spike.x = player.x; |
| 519 |
spike.y = player.y; |
| 520 |
const site = siteAt(player.x, player.y); |
| 521 |
if (player.input.use && site) { |
| 522 |
player.planting += dt; |
| 523 |
if (player.planting >= PLANT_TIME) { |
| 524 |
spike.planted = true; |
| 525 |
spike.site = site; |
| 526 |
spike.fuse = FUSE_TIME; |
| 527 |
spike.carried = null; |
| 528 |
player.spike = false; |
| 529 |
player.planting = 0; |
| 530 |
player.ult = Math.min(10, player.ult + 1); |
| 531 |
for (const mate of teammates(state, "atk")) mate.credits = award(mate.credits, 300); |
| 532 |
say(state, `${player.name} planted on ${site}`); |
| 533 |
} |
| 534 |
} else player.planting = 0; |
| 535 |
} |
| 536 |
return; |
| 537 |
} |
| 538 |
if (player.side === "def" && spike.planted) { |
| 539 |
const close = Math.hypot(player.x - spike.x, player.y - spike.y) < SPIKE_REACH; |
| 540 |
if (player.input.use && close) { |
| 541 |
spike.defuse += dt; |
| 542 |
player.defusing = spike.defuse; |
| 543 |
|
| 544 |
if (spike.defuse >= DEFUSE_CHECKPOINT && spike.checkpoint === 0) { |
| 545 |
spike.checkpoint = DEFUSE_CHECKPOINT; |
| 546 |
say(state, `${player.name} is half way through the defuse`); |
| 547 |
} |
| 548 |
} else if (player.defusing > 0) { |
| 549 |
player.defusing = 0; |
| 550 |
spike.defuse = spike.checkpoint; |
| 551 |
} |
| 552 |
} |
| 553 |
} |
| 554 |
|
| 555 |
function orbs(state: MatchState, player: Player): void { |
| 556 |
for (const orb of state.orbs) |
| 557 |
if (!orb.taken && Math.hypot(player.x - orb.x, player.y - orb.y) < 40) { |
| 558 |
orb.taken = true; |
| 559 |
player.ult = Math.min(10, player.ult + 1); |
| 560 |
say(state, `${player.name} took an ult orb`); |
| 561 |
} |
| 562 |
} |
| 563 |
|
| 564 |
function zones(state: MatchState, dt: number): void { |
| 565 |
for (const zone of state.zones) { |
| 566 |
if (zone.fuse > 0) { |
| 567 |
zone.fuse -= dt; |
| 568 |
if (zone.fuse > 0) continue; |
| 569 |
if (zone.kind === "flash") { |
| 570 |
|
| 571 |
|
| 572 |
|
| 573 |
for (const player of state.players) { |
| 574 |
if (!player.alive) continue; |
| 575 |
const blocked = sightBlocked(zone, player, state.zones); |
| 576 |
const blind = flashDuration(zone.damage, player, zone, player.angle, blocked, zone.r); |
| 577 |
if (blind > 0) player.blind = Math.max(player.blind, blind); |
| 578 |
} |
| 579 |
} |
| 580 |
continue; |
| 581 |
} |
| 582 |
zone.life -= dt; |
| 583 |
zone.beat -= dt; |
| 584 |
if (zone.kind === "molly" && zone.beat <= 0) { |
| 585 |
zone.beat = 0.5; |
| 586 |
const owner = state.players.find((p) => p.id === zone.owner) ?? null; |
| 587 |
for (const player of state.players) |
| 588 |
if (player.alive && player.side !== zone.side && Math.hypot(player.x - zone.x, player.y - zone.y) <= zone.r) |
| 589 |
hurt(state, player, Math.round(zone.damage / 2), owner, ""); |
| 590 |
} |
| 591 |
if (zone.kind === "dart" && zone.beat <= 0 && zone.pulses > 0) { |
| 592 |
zone.beat = 1.6; |
| 593 |
zone.pulses -= 1; |
| 594 |
for (const player of state.players) |
| 595 |
if (player.alive && player.side !== zone.side && Math.hypot(player.x - zone.x, player.y - zone.y) <= zone.r && !sightBlocked(zone, player, state.zones)) |
| 596 |
player.revealed = Math.max(player.revealed, 2); |
| 597 |
} |
| 598 |
if (zone.kind === "heal" && zone.beat <= 0) { |
| 599 |
zone.beat = 0.5; |
| 600 |
const target = state.players.find((p) => p.id === zone.owner); |
| 601 |
if (target && target.alive) { |
| 602 |
target.hp = Math.min(100, target.hp + zone.damage / 6); |
| 603 |
zone.x = target.x; |
| 604 |
zone.y = target.y; |
| 605 |
} |
| 606 |
} |
| 607 |
if (zone.kind === "trip" && zone.life > 0) { |
| 608 |
const caught = state.players.find( |
| 609 |
(p) => p.alive && p.side !== zone.side && Math.hypot(p.x - zone.x, p.y - zone.y) <= zone.r, |
| 610 |
); |
| 611 |
if (caught) { |
| 612 |
caught.revealed = Math.max(caught.revealed, 4); |
| 613 |
caught.slow = Math.max(caught.slow, 3); |
| 614 |
zone.life = 0; |
| 615 |
say(state, `${caught.name} tripped a wire`); |
| 616 |
} |
| 617 |
} |
| 618 |
|
| 619 |
if (zone.kind === "smoke" && zone.damage < 0) |
| 620 |
for (const player of state.players) |
| 621 |
if (player.alive && player.side !== zone.side && Math.hypot(player.x - zone.x, player.y - zone.y) <= zone.r) |
| 622 |
player.slow = Math.max(player.slow, 0.5); |
| 623 |
} |
| 624 |
state.zones = state.zones.filter((z) => z.life > 0 || z.fuse > 0); |
| 625 |
} |
| 626 |
|
| 627 |
function endConditions(state: MatchState): void { |
| 628 |
const spike = state.spike; |
| 629 |
if (spike.planted && spike.defuse >= DEFUSE_TIME) return finishRound(state, "def", "Spike defused"); |
| 630 |
if (aliveOn(state, "def").length === 0) return finishRound(state, "atk", "Defenders eliminated"); |
| 631 |
if (aliveOn(state, "atk").length === 0 && !spike.planted) |
| 632 |
return finishRound(state, "def", "Attackers eliminated"); |
| 633 |
if (spike.planted) { |
| 634 |
if (spike.fuse <= 0) return finishRound(state, "atk", "Spike detonated"); |
| 635 |
} else if (state.clock <= 0) return finishRound(state, "def", "Time out"); |
| 636 |
} |
| 637 |
|
| 638 |
function finishRound(state: MatchState, winners: Side, why: string): void { |
| 639 |
const winningTeam: Team = winners === "atk" ? state.attackers : state.attackers === "red" ? "blue" : "red"; |
| 640 |
const losingTeam: Team = winningTeam === "red" ? "blue" : "red"; |
| 641 |
state.score[winningTeam] += 1; |
| 642 |
state.lossStreak[losingTeam] += 1; |
| 643 |
state.lossStreak[winningTeam] = 0; |
| 644 |
for (const player of state.players) { |
| 645 |
const won = player.team === winningTeam; |
| 646 |
player.credits = clampCredits( |
| 647 |
player.credits + roundReward(won, state.lossStreak[player.team], state.spike.planted && player.side === "atk"), |
| 648 |
); |
| 649 |
} |
| 650 |
state.phase = "end"; |
| 651 |
state.clock = END_TIME; |
| 652 |
state.last = { round: state.round, winner: winningTeam, why }; |
| 653 |
say(state, `${why} — ${winningTeam} take round ${state.round} (${state.score.red}–${state.score.blue})`); |
| 654 |
if (state.score[winningTeam] >= WIN_ROUNDS) { |
| 655 |
state.phase = "over"; |
| 656 |
state.clock = OVER_TIME; |
| 657 |
state.result = `${winningTeam} win ${state.score.red}–${state.score.blue}`; |
| 658 |
say(state, `Match over — ${state.result}. A new one starts in ${OVER_TIME} seconds.`); |
| 659 |
} |
| 660 |
} |
| 661 |
|
| 662 |
function nextRound(state: MatchState): void { |
| 663 |
state.round += 1; |
| 664 |
|
| 665 |
if (state.round === SWAP_AFTER + 1) { |
| 666 |
state.attackers = state.attackers === "red" ? "blue" : "red"; |
| 667 |
say(state, "Sides swap"); |
| 668 |
} |
| 669 |
startRound(state); |
| 670 |
say(state, `Round ${state.round} — buy phase`); |
| 671 |
} |
| 672 |
|
| 673 |
|
| 674 |
export function visible(state: MatchState, viewer: Player, other: Player): boolean { |
| 675 |
if (other.id === viewer.id || other.side === viewer.side) return true; |
| 676 |
if (!other.alive) return false; |
| 677 |
if (other.revealed > 0) return true; |
| 678 |
if (viewer.blind > 0) return false; |
| 679 |
|
| 680 |
|
| 681 |
|
| 682 |
if (!viewer.alive) |
| 683 |
return state.players.some( |
| 684 |
(mate) => mate.side === viewer.side && mate.alive && mate.blind <= 0 && !sightBlocked(mate, other, state.zones), |
| 685 |
); |
| 686 |
return !sightBlocked(viewer, other, state.zones); |
| 687 |
} |
| 688 |
|
| 689 |
|
| 690 |
|
| 691 |
const normalizeAngle = (angle: number): number => ((angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI; |
| 692 |
|
| 693 |
export function setInput(player: Player, input: Partial<Input>): void { |
| 694 |
const target = player.input; |
| 695 |
if (typeof input.mx === "number" && Number.isFinite(input.mx)) target.mx = Math.max(-1, Math.min(1, input.mx)); |
| 696 |
if (typeof input.my === "number" && Number.isFinite(input.my)) target.my = Math.max(-1, Math.min(1, input.my)); |
| 697 |
if (typeof input.angle === "number" && Number.isFinite(input.angle)) target.angle = normalizeAngle(input.angle); |
| 698 |
if (typeof input.fire === "boolean") target.fire = input.fire; |
| 699 |
if (typeof input.use === "boolean") target.use = input.use; |
| 700 |
if (typeof input.reload === "boolean") target.reload = input.reload; |
| 701 |
if (input.cast === "c" || input.cast === "q" || input.cast === "e" || input.cast === "x") target.cast = input.cast; |
| 702 |
} |
| 703 |
|
| 704 |
export { castable, agentById }; |