fable/val Public Public: anyone can read this; nobody can change it.

forked from benjivers/val

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

Keyboard shortcuts