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

forked from benjivers/val

workbenchthis machine
Files cdce38683437375e2145e657f55d282173240782
1
/**
2
 * val, in a browser. It draws what the server sends and sends what the
3
 * person does — nothing else. There is no position here the server did not
4
 * hand over, and no enemy in a snapshot the server decided you cannot see,
5
 * so a patched client learns nothing a plain one does not.
6
 *
7
 * This file is the wiring: the socket, the keys and the pointer, the HUD.
8
 * The drawing lives beside it — `scene.js` for the first-person view,
9
 * `topdown.js` for the map, `gun.js` for the crosshair, `prompt.js` for the
10
 * line of advice and `raycast.js` for the maths all of it stands on.
11
 */
12
"use strict";
13
14
import { makeBeeper } from "./beep.js";
15
import { BODY_R, moveSpeed } from "../src/glide.ts";
16
import { drawGun, gunSnapshot, gunTick, makeGun } from "./gun.js";
17
import { advance, anchor, holdBack, insideWall, makeLocal, project, reconcile } from "./predict.js";
18
import { choosePrompt, REACH } from "./prompt.js";
19
import { roster, rosterKey } from "./roster.js";
20
import { AGENT_COLOUR, drawScene, makeScene } from "./scene.js";
21
import { moveVector } from "./steer.js";
22
import { drawTopdown } from "./topdown.js";
23
24
const $ = (id) => document.getElementById(id);
25
const view = $("view");
26
const ctx = view.getContext("2d");
27
28
const KEY_SLOT = { KeyC: "c", KeyQ: "q", KeyE: "e", KeyX: "x" };
29
const COARSE = matchMedia("(pointer: coarse)").matches;
30
const LOOK = 0.0022; // radians per pixel of mouse travel
31
const HELP_SEEN = "val.help.seen";
32
33
let map = null;
34
let solid = () => true;
35
let world = null; // what predict.js slides against: the grid, from map.json
36
let snap = null;
37
let socket = null;
38
let agents = null;
39
const shown = new Map(); // smoothed positions, so 15 frames a second look like 60
40
const keys = new Set();
41
let yaw = 0; // where this person is looking, theirs to move between snapshots
42
let locked = false;
43
let firing = false;
44
let mode = COARSE ? "top" : "fp"; // a raycaster with touch look is miserable
45
let mapOpen = false;
46
let pointer = null; // aim target in canvas pixels, top-down only
47
let stick = null; // { id, ox, oy, mx, my }
48
let aimTouch = null;
49
let helpShown = false;
50
let last = performance.now();
51
let snapAt = last; // when the last snapshot landed: how stale the answer is
52
53
const walk = { x: 0, y: 0 };
54
// What this person is doing right now, read once a frame for the drawing and
55
// once every 50ms for the server. The same object either way: no allocation
56
// in a loop that runs sixty times a second.
57
const intent = { mx: 0, my: 0, angle: 0, fire: false, use: false, reload: false };
58
// Your own body, run forward from your own keys between snapshots, and the
59
// object the drawing is handed for it.
60
const local = makeLocal();
61
const drawnMe = { id: "", x: 0, y: 0, angle: 0 };
62
const scene = makeScene();
63
const gun = makeGun();
64
const beeper = makeBeeper();
65
66
// ------------------------------------------------------------------ socket
67
68
function connect() {
69
  const url = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/ws";
70
  socket = new WebSocket(url);
71
  socket.onopen = () => ($("conn").textContent = "live");
72
  socket.onclose = () => {
73
    $("conn").textContent = "reconnecting…";
74
    setTimeout(connect, 1500);
75
  };
76
  socket.onmessage = (event) => {
77
    const data = JSON.parse(event.data);
78
    if (data.t !== "s") return;
79
    const first = snap === null;
80
    snap = data;
81
    snapAt = performance.now();
82
    if (first && mode === "fp") yaw = data.me.angle;
83
    gunSnapshot(gun, data);
84
    paintHud(data);
85
    maybeHelp(data);
86
  };
87
}
88
89
const send = (message) => {
90
  if (socket && socket.readyState === 1) socket.send(JSON.stringify(message));
91
};
92
93
// ------------------------------------------------------------------- input
94
95
function setMode(next) {
96
  mode = next;
97
  $("stage").dataset.mode = next;
98
  if (next !== "fp" && document.pointerLockElement === view) document.exitPointerLock();
99
  if (snap) yaw = snap.me.angle;
100
}
101
102
addEventListener("keydown", (event) => {
103
  if (event.target instanceof HTMLInputElement) return;
104
  if (event.code === "Tab") {
105
    mapOpen = true;
106
    event.preventDefault();
107
    return;
108
  }
109
  if (event.code === "KeyH" || event.key === "?") {
110
    toggleHelp();
111
    event.preventDefault();
112
    return;
113
  }
114
  if (event.code === "KeyV") {
115
    setMode(mode === "fp" ? "top" : "fp");
116
    event.preventDefault();
117
    return;
118
  }
119
  if (event.code === "KeyB") {
120
    toggleBuy();
121
    event.preventDefault();
122
    return;
123
  }
124
  if (KEY_SLOT[event.code]) {
125
    send({ t: "i", c: KEY_SLOT[event.code] });
126
    event.preventDefault();
127
    return;
128
  }
129
  keys.add(event.code);
130
  if (["KeyW", "KeyA", "KeyS", "KeyD", "Space", "KeyF", "KeyR", "ArrowLeft", "ArrowRight"].includes(event.code))
131
    event.preventDefault();
132
});
133
addEventListener("keyup", (event) => {
134
  if (event.code === "Tab") mapOpen = false;
135
  keys.delete(event.code);
136
});
137
addEventListener("blur", () => {
138
  keys.clear();
139
  mapOpen = false;
140
  firing = false;
141
});
142
143
// Aiming is pointer lock: click the canvas to capture the mouse, move to
144
// look, Escape gives it back. The angle sent is the same angle as ever.
145
view.addEventListener("click", () => {
146
  beeper.wake();
147
  if (mode !== "fp" || locked) return;
148
  if (!view.requestPointerLock) return setMode("top"); // no lock, no first person
149
  // Asked for too soon after the last release, a browser rejects this; that
150
  // is a shrug, not an error, and the hint stays up until it takes.
151
  const asked = view.requestPointerLock();
152
  if (asked && asked.catch) asked.catch(() => {});
153
});
154
document.addEventListener("pointerlockchange", () => {
155
  locked = document.pointerLockElement === view;
156
  if (!locked) firing = false;
157
  $("stage").dataset.locked = locked ? "1" : "";
158
});
159
addEventListener("mousemove", (event) => {
160
  if (locked) yaw += event.movementX * LOOK;
161
});
162
addEventListener("mousedown", (event) => {
163
  if (locked && event.button === 0) firing = true;
164
});
165
addEventListener("mouseup", (event) => {
166
  if (locked && event.button === 0) firing = false;
167
});
168
169
// The top-down view keeps the pointer aim it always had: it is the phone
170
// control scheme, and the fallback when the first-person view is not wanted.
171
const canvasPoint = (event) => {
172
  const box = view.getBoundingClientRect();
173
  return {
174
    x: ((event.clientX - box.left) / box.width) * view.width,
175
    y: ((event.clientY - box.top) / box.height) * view.height,
176
  };
177
};
178
view.addEventListener("pointermove", (event) => {
179
  if (mode !== "top" || event.pointerType === "touch") return;
180
  pointer = canvasPoint(event);
181
});
182
view.addEventListener("pointerdown", (event) => {
183
  beeper.wake();
184
  if (mode !== "top") return;
185
  view.setPointerCapture(event.pointerId);
186
  const box = view.getBoundingClientRect();
187
  if (event.pointerType === "touch") {
188
    if (event.clientX - box.left < box.width / 2)
189
      stick = { id: event.pointerId, ox: event.clientX, oy: event.clientY, mx: 0, my: 0 };
190
    else {
191
      aimTouch = { id: event.pointerId, ...canvasPoint(event) };
192
      firing = true;
193
    }
194
    event.preventDefault();
195
    return;
196
  }
197
  pointer = canvasPoint(event);
198
  firing = true;
199
});
200
view.addEventListener("pointermove", (event) => {
201
  if (stick && event.pointerId === stick.id) {
202
    const dx = event.clientX - stick.ox;
203
    const dy = event.clientY - stick.oy;
204
    const len = Math.hypot(dx, dy) || 1;
205
    const reach = Math.min(len, 48) / 48;
206
    stick.mx = (dx / len) * reach;
207
    stick.my = (dy / len) * reach;
208
  }
209
  if (aimTouch && event.pointerId === aimTouch.id) Object.assign(aimTouch, canvasPoint(event));
210
});
211
const endPointer = (event) => {
212
  if (stick && event.pointerId === stick.id) stick = null;
213
  else if (aimTouch && event.pointerId === aimTouch.id) {
214
    aimTouch = null;
215
    firing = false;
216
  } else firing = false;
217
};
218
view.addEventListener("pointerup", endPointer);
219
view.addEventListener("pointercancel", endPointer);
220
view.addEventListener("contextmenu", (event) => event.preventDefault());
221
222
// What this person is doing, in the world's terms. In the first-person view
223
// the keys are read against the way you are facing; in the top-down view they
224
// are the compass, the way they always were.
225
function readIntent() {
226
  let ax = 0;
227
  let ay = 0;
228
  if (keys.has("KeyW")) ay -= 1;
229
  if (keys.has("KeyS")) ay += 1;
230
  if (keys.has("KeyA")) ax -= 1;
231
  if (keys.has("KeyD")) ax += 1;
232
  if (stick) {
233
    ax = stick.mx;
234
    ay = stick.my;
235
  }
236
  intent.mx = ax;
237
  intent.my = ay;
238
  if (mode === "fp") {
239
    intent.angle = yaw;
240
    moveVector(ax, ay, yaw, walk);
241
    intent.mx = walk.x;
242
    intent.my = walk.y;
243
  } else {
244
    const aim = aimTouch || pointer;
245
    const me = worldToScreen(drawnMe.id ? drawnMe : snap.me);
246
    intent.angle = aim ? Math.atan2(aim.y - me.y, aim.x - me.x) : snap.me.angle;
247
    yaw = intent.angle;
248
  }
249
  intent.fire = firing || keys.has("Space");
250
  intent.use = keys.has("KeyF");
251
  intent.reload = keys.has("KeyR");
252
  return intent;
253
}
254
255
// Twenty times a second the server hears it. Intent only — a direction, an
256
// angle, a held key — never a position: the guess below is for drawing.
257
setInterval(() => {
258
  if (!snap || !map) return;
259
  const now = readIntent();
260
  send({ t: "i", mx: now.mx, my: now.my, a: now.angle, f: now.fire, u: now.use, r: now.reload });
261
}, 50);
262
263
// ------------------------------------------------------------------ drawing
264
265
function resize() {
266
  const box = view.getBoundingClientRect();
267
  const dpr = Math.min(devicePixelRatio || 1, 2);
268
  view.width = Math.round(box.width * dpr);
269
  view.height = Math.round(box.height * dpr);
270
}
271
addEventListener("resize", resize);
272
273
const scale = () => (map ? view.width / (map.rows[0].length * map.tile) : 1);
274
const worldToScreen = (p) => ({ x: p.x * scale(), y: p.y * scale() });
275
276
function smooth(id, x, y, angle) {
277
  const now = shown.get(id);
278
  if (!now) {
279
    const fresh = { x, y, angle };
280
    shown.set(id, fresh);
281
    return fresh;
282
  }
283
  now.x += (x - now.x) * 0.35;
284
  now.y += (y - now.y) * 0.35;
285
  const turn = ((angle - now.angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI;
286
  now.angle += turn * 0.4;
287
  return now;
288
}
289
290
/**
291
 * Everybody else is smoothed toward the last position the server sent.
292
 * You are not: your own body is the one this browser can run forward
293
 * honestly, and smoothing it is what dragged the camera backwards.
294
 */
295
const look = (p) => (predicting() && p.id === snap.me.id ? drawnMe : smooth(p.id, p.x, p.y, p.angle));
296
297
/**
298
 * Your own movement is only guessed at while the round is live and you are
299
 * alive. The buy phase pens everybody in their own spawn — a rule of the
300
 * match, not of the map — and a guess that does not know it would spend the
301
 * phase being pulled back through the door.
302
 */
303
const predicting = () => snap !== null && world !== null && snap.phase !== "buy" && snap.me.alive;
304
305
/** The server's answer, caught up to now, then this frame of your own keys. */
306
function predict(dt, now) {
307
  if (!predicting()) {
308
    local.ready = false;
309
    drawnMe.id = "";
310
    return;
311
  }
312
  const held = readIntent();
313
  const speed = moveSpeed({
314
    haste: snap.me.haste > 0,
315
    slow: snap.me.slow > 0,
316
    stopped: snap.me.planting > 0 || snap.me.defusing,
317
    heavy: snap.me.weapon === "operator",
318
  });
319
  // The snapshot is a moment old and the keys held since have not reached
320
  // it; carry it that far forward and lean the drawn body onto it.
321
  const target = project(snap.me, held.mx, held.my, speed, (now - snapAt) / 1000, world);
322
  reconcile(local, target, dt);
323
  advance(local, held.mx, held.my, speed, dt, world);
324
  holdBack(local, target, held.mx, held.my);
325
  // A lean across a corner could put the drawn body in a wall; the server's
326
  // answer is never wrong, so take it rather than draw from inside one.
327
  if (insideWall(local, world)) anchor(local, snap.me);
328
  drawnMe.id = snap.me.id;
329
  drawnMe.x = local.x;
330
  drawnMe.y = local.y;
331
  drawnMe.angle = mode === "fp" ? yaw : snap.me.angle;
332
}
333
334
function frame(now) {
335
  requestAnimationFrame(frame);
336
  const dt = Math.min(0.1, (now - last) / 1000);
337
  last = now;
338
  if (!map) return;
339
  gunTick(gun, dt);
340
  ctx.clearRect(0, 0, view.width, view.height);
341
342
  if (!snap) {
343
    drawTopdown(ctx, map, null, look, { x: 0, y: 0, w: view.width, h: view.height });
344
    return;
345
  }
346
  // Turning with the arrow keys belongs to the frame, at a rate a second,
347
  // rather than to whatever the input timer happens to do.
348
  if (mode === "fp" && keys.has("ArrowLeft")) yaw -= 0.9 * dt;
349
  if (mode === "fp" && keys.has("ArrowRight")) yaw += 0.9 * dt;
350
  predict(dt, now);
351
  beeper.update(snap.spike.planted, snap.spike.fuse, now / 1000);
352
  const eye = look(snap.me);
353
  const facing = mode === "fp" ? yaw : eye.angle;
354
355
  if (mode === "fp" && !mapOpen) {
356
    drawScene(ctx, scene, { solid, tile: map.tile }, { x: eye.x, y: eye.y, angle: facing }, snap, {
357
      width: view.width,
358
      height: view.height,
359
      look,
360
    });
361
    drawGun(ctx, gun, view.width, view.height, snap);
362
    drawMinimap();
363
  } else {
364
    drawTopdown(ctx, map, snap, look, { x: 0, y: 0, w: view.width, h: view.height });
365
  }
366
367
  if (snap.me.planting > 0) bar(snap.me.planting / 4, "#e0a040", "planting");
368
  else if (snap.spike.planted && snap.spike.defuse > 0 && snap.me.side === "def")
369
    bar(snap.spike.defuse / 7, "#4fd6a0", "defusing");
370
  $("flash").style.opacity = Math.min(0.95, snap.me.blind * 0.8);
371
  $("dead").style.display = snap.me.alive ? "none" : "grid";
372
  $("aimhint").style.display = mode === "fp" && !locked && !COARSE ? "grid" : "none";
373
}
374
375
/** The same top-down drawing, small, in the corner, with you as an arrow. */
376
function drawMinimap() {
377
  const w = Math.min(view.width * 0.3, 260 * (view.width / 900));
378
  const h = (w * map.rows.length) / map.rows[0].length;
379
  const pad = 8;
380
  ctx.save();
381
  ctx.globalAlpha = 0.85;
382
  ctx.fillStyle = "#0b0d11";
383
  ctx.fillRect(pad - 2, pad - 2, w + 4, h + 4);
384
  drawTopdown(ctx, map, snap, look, { x: pad, y: pad, w, h }, { labels: false });
385
  ctx.restore();
386
  ctx.strokeStyle = "rgba(255,255,255,0.18)";
387
  ctx.lineWidth = 1;
388
  ctx.strokeRect(pad - 2, pad - 2, w + 4, h + 4);
389
}
390
391
function bar(fraction, colour, label) {
392
  const w = view.width * 0.4;
393
  const x = view.width / 2 - w / 2;
394
  const y = view.height * 0.86;
395
  ctx.fillStyle = "#0009";
396
  ctx.fillRect(x, y, w, 16);
397
  ctx.fillStyle = colour;
398
  ctx.fillRect(x, y, w * Math.min(1, fraction), 16);
399
  ctx.fillStyle = "#fff";
400
  ctx.font = "12px system-ui, sans-serif";
401
  ctx.textAlign = "center";
402
  ctx.fillText(label, view.width / 2, y + 12);
403
  ctx.textAlign = "left";
404
}
405
406
// --------------------------------------------------------------------- hud
407
408
// The buy opens itself every buy phase; closing it is remembered until the
409
// next one, so nobody has to fight the panel to watch the round start.
410
function toggleBuy() {
411
  const open = $("buy").classList.toggle("on");
412
  $("buy").dataset.closed = open ? "" : "1";
413
}
414
$("buytoggle").addEventListener("click", toggleBuy);
415
$("viewtoggle").addEventListener("click", () => setMode(mode === "fp" ? "top" : "fp"));
416
$("helptoggle").addEventListener("click", () => toggleHelp());
417
$("helpclose").addEventListener("click", () => toggleHelp(false));
418
for (const button of document.querySelectorAll("[data-buy]"))
419
  button.addEventListener("click", () => send({ t: "buy", what: button.dataset.buy }));
420
for (const button of document.querySelectorAll("[data-agent]"))
421
  button.addEventListener("click", () => send({ t: "agent", id: button.dataset.agent }));
422
423
function toggleHelp(force) {
424
  const open = force === undefined ? $("help").style.display !== "grid" : force;
425
  $("help").style.display = open ? "grid" : "none";
426
  if (!open) {
427
    helpShown = true;
428
    try {
429
      localStorage.setItem(HELP_SEEN, "1");
430
    } catch {
431
      // A browser that refuses storage still gets the card, every visit.
432
    }
433
  }
434
}
435
436
/** The first-run card, once per browser, and never over a round in progress. */
437
function maybeHelp(s) {
438
  if (helpShown) return;
439
  try {
440
    if (localStorage.getItem(HELP_SEEN) === "1") {
441
      helpShown = true;
442
      return;
443
    }
444
  } catch {
445
    /* no storage: show it and move on */
446
  }
447
  if (s.phase === "live" && s.me.alive) return;
448
  helpShown = true;
449
  $("help").style.display = "grid";
450
}
451
452
const KITS = {};
453
454
function paintHud(s) {
455
  $("clock").textContent = Math.ceil(s.phase === "live" && s.spike.planted ? s.spike.fuse : s.clock);
456
  document.querySelector("#scoreline .red").textContent = "red " + s.score.red;
457
  document.querySelector("#scoreline .blue").textContent = s.score.blue + " blue";
458
  $("phase").textContent =
459
    `round ${s.round} · ${s.phase === "buy" ? "buy phase" : s.phase === "live" ? (s.spike.planted ? "spike down" : "live") : s.phase === "end" ? "round over" : "match over"} · ${s.attackers} attacking · you are ${s.me.side === "atk" ? "attacking" : "defending"}${s.result ? " · " + s.result : ""}`;
460
  $("hp").textContent = `${s.me.hp} hp${s.me.armour ? " +" + s.me.armour : ""}`;
461
  $("gun").textContent = `${s.me.weapon}${s.me.reloading ? " reloading" : " " + s.me.ammo}`;
462
  $("creds").textContent = `${s.me.credits} cr`;
463
  $("ultmeter").textContent = `ult ${s.me.ult}/${s.me.ultNeeds}`;
464
  $("buy").classList.toggle("on", s.phase === "buy" && $("buy").dataset.closed !== "1");
465
  if (s.phase !== "buy") $("buy").dataset.closed = "";
466
467
  const text = choosePrompt(s, {
468
    onSite: onSite(s.me),
469
    nearSpike: s.spike.x !== null && Math.hypot(s.spike.x - s.me.x, s.spike.y - s.me.y) < REACH,
470
  });
471
  if ($("prompt").textContent !== text) $("prompt").textContent = text;
472
473
  paintKit(s);
474
  paintBuy(s);
475
  paintRoster(s);
476
  paintFeed(s);
477
478
  const log = $("log");
479
  const lines = s.events.slice(0, 3).join("\n");
480
  if (log.dataset.text !== lines) {
481
    log.dataset.text = lines;
482
    log.replaceChildren();
483
    for (const line of s.events.slice(0, 3)) {
484
      const div = document.createElement("div");
485
      div.textContent = line;
486
      log.append(div);
487
    }
488
  }
489
490
  for (const side of ["atk", "def"]) {
491
    const body = $(side + "board");
492
    const rows = s.board.filter((p) => p.side === side).sort((a, b) => b.kills - a.kills);
493
    const key = rows.map((p) => `${p.name}${p.kills}${p.deaths}${p.alive}${p.credits}`).join();
494
    if (body.dataset.key === key) continue;
495
    body.dataset.key = key;
496
    body.replaceChildren();
497
    for (const player of rows) {
498
      const tr = document.createElement("tr");
499
      const cells = [
500
        player.name + (player.bot ? " (bot)" : "") + (player.alive ? "" : " ✕"),
501
        player.agent + (player.credits !== null ? ` · ${player.credits}cr` : ""),
502
        String(player.kills),
503
        String(player.deaths),
504
      ];
505
      for (const value of cells) {
506
        const td = document.createElement("td");
507
        td.textContent = value;
508
        tr.append(td);
509
      }
510
      if (player.id === s.me.id) tr.style.fontWeight = "700";
511
      body.append(tr);
512
    }
513
  }
514
}
515
516
/** Is this player standing where the spike arms? The grid says so. */
517
function onSite(me) {
518
  if (!map) return false;
519
  const row = map.rows[Math.floor(me.y / map.tile)];
520
  const cell = row ? row[Math.floor(me.x / map.tile)] : "";
521
  return cell === "A" || cell === "B";
522
}
523
524
/**
525
 * What just happened to whom: newest first, and gone a few seconds later.
526
 * The server sends names and a gun and nothing else, so there is nothing
527
 * here to read a position out of.
528
 */
529
function paintFeed(s) {
530
  const box = $("feed");
531
  const lines = (s.feed ?? []).slice().reverse();
532
  // The age is bucketed into the key: an entry fades once on its way out
533
  // rather than rebuilding the panel every frame.
534
  const key = lines.map((l) => `${l.k}>${l.v}:${l.w}:${l.h}:${l.t > 6 ? "old" : "new"}`).join("|");
535
  if (box.dataset.key === key) return;
536
  box.dataset.key = key;
537
  box.replaceChildren();
538
  for (const line of lines) {
539
    const row = document.createElement("div");
540
    row.style.opacity = line.t > 6 ? "0.5" : "1";
541
    const killer = document.createElement("span");
542
    killer.className = "k";
543
    killer.textContent = line.k ?? "—";
544
    const victim = document.createElement("span");
545
    victim.className = "v";
546
    victim.textContent = line.v;
547
    const how = document.createElement("span");
548
    how.className = "w";
549
    how.textContent = `${line.w ? " " + line.w : " ability"}${line.h ? " ✧" : ""}`;
550
    const arrow = document.createElement("span");
551
    arrow.textContent = " ▸ ";
552
    row.append(killer, arrow, victim, how);
553
    box.append(row);
554
  }
555
}
556
557
/**
558
 * Your team, always on screen: five slots, health, and struck through when
559
 * somebody is dead. The one place both questions — who is with me, who is
560
 * still up — are answered whether anybody is in sight or not.
561
 */
562
function paintRoster(s) {
563
  const rows = roster(s);
564
  const panel = $("roster");
565
  const key = rosterKey(rows);
566
  if (panel.dataset.key === key) return;
567
  panel.dataset.key = key;
568
  panel.replaceChildren();
569
  for (const row of rows) {
570
    const slot = document.createElement("div");
571
    slot.className = `slot${row.mine ? " mine" : ""}${row.alive ? "" : " down"}`;
572
    slot.style.borderLeftColor = AGENT_COLOUR[row.agent] || "#4a5162";
573
    const who = document.createElement("span");
574
    who.className = "who";
575
    who.textContent = row.name;
576
    const agent = document.createElement("span");
577
    agent.className = "agent";
578
    agent.textContent = row.alive ? row.agent : `${row.agent} · dead`;
579
    if (row.spike) {
580
      const spike = document.createElement("span");
581
      spike.className = "spike";
582
      spike.textContent = " spike";
583
      agent.append(spike);
584
    }
585
    const bar = document.createElement("span");
586
    bar.className = "hp";
587
    const fill = document.createElement("i");
588
    fill.style.width = `${row.hp}%`;
589
    fill.style.background = row.hp > 40 ? "#6fcf6f" : "#e0a040";
590
    bar.append(fill);
591
    slot.append(who, agent, bar);
592
    panel.append(slot);
593
  }
594
}
595
596
const kitOf = (id) => (agents ? agents.find((a) => a.id === id) : null);
597
598
/** The four ability buttons, each saying what it does and what it has left. */
599
function paintKit(s) {
600
  const kit = $("kit");
601
  const agent = kitOf(s.me.agent);
602
  const shape =
603
    ["c", "q", "e", "x"].map((slot) => `${slot}${s.me.charges[slot]}${Math.ceil(s.me.cooldown[slot])}`).join("|") +
604
    s.me.agent +
605
    s.me.ult +
606
    (agent ? "k" : "");
607
  if (KITS.shape === shape) return;
608
  KITS.shape = shape;
609
  kit.replaceChildren();
610
  for (const slot of ["c", "q", "e", "x"]) {
611
    const ability = agent ? agent.abilities[slot] : null;
612
    const button = document.createElement("button");
613
    const ready = slot === "x" ? s.me.ult >= s.me.ultNeeds : s.me.charges[slot] > 0 && s.me.cooldown[slot] <= 0;
614
    button.disabled = !ready;
615
    button.type = "button";
616
    const count =
617
      slot === "x"
618
        ? `${s.me.ult}/${s.me.ultNeeds}`
619
        : s.me.cooldown[slot] > 0
620
          ? `${Math.ceil(s.me.cooldown[slot])}s`
621
          : s.me.charges[slot];
622
    const name = document.createElement("span");
623
    name.textContent = `${slot.toUpperCase()}${ability ? " " + ability.name : ""}`;
624
    const tally = document.createElement("b");
625
    tally.textContent = String(count);
626
    button.append(name, tally);
627
    if (ability) button.title = `${ability.name} — ${ability.text}`;
628
    button.addEventListener("click", () => send({ t: "i", c: slot }));
629
    kit.append(button);
630
  }
631
}
632
633
/** Prices on every button, greyed when you cannot afford it. */
634
function paintBuy(s) {
635
  const agent = kitOf(s.me.agent);
636
  for (const button of document.querySelectorAll("[data-buy]")) {
637
    const what = button.dataset.buy;
638
    let cost = Number(button.dataset.cost ?? 0);
639
    if ((what === "c" || what === "q") && agent) {
640
      const ability = agent.abilities[what];
641
      cost = ability.cost;
642
      button.replaceChildren();
643
      const name = document.createElement("span");
644
      name.textContent = `${what.toUpperCase()} ${ability.name}`;
645
      const price = document.createElement("b");
646
      price.textContent = `${ability.cost} · ${s.me.charges[what]}/${ability.charges}`;
647
      button.append(name, price);
648
      button.title = `${ability.text} (${ability.charges} charge${ability.charges === 1 ? "" : "s"} a round)`;
649
    }
650
    button.disabled = cost > s.me.credits;
651
  }
652
  for (const button of document.querySelectorAll("[data-agent]")) {
653
    const picked = button.dataset.agent === s.me.agent;
654
    button.style.outline = picked ? "2px solid #6fcf9f" : "";
655
  }
656
}
657
658
// ------------------------------------------------------------------- start
659
660
Promise.all([fetch("/map.json").then((r) => r.json()), fetch("/agents.json").then((r) => r.json())])
661
  .then(([mapData, kit]) => {
662
    map = mapData;
663
    agents = kit.agents;
664
    const rows = map.rows;
665
    const cols = rows[0].length;
666
    solid = (col, row) =>
667
      col < 0 || row < 0 || row >= rows.length || col >= cols || rows[row][col] === "#";
668
    // The same grid the server collides against, so a guess about your own
669
    // movement lands where the server's answer is about to.
670
    world = { solid, tile: map.tile, r: BODY_R };
671
    $("stage").dataset.mode = mode;
672
    fillHelp();
673
    resize();
674
    connect();
675
  })
676
  .catch(() => ($("conn").textContent = "offline"));
677
678
/** The card says the round, the sides, the keys and the kit, in that order. */
679
function fillHelp() {
680
  const list = $("helpagents");
681
  if (!list || !agents) return;
682
  list.replaceChildren();
683
  for (const agent of agents) {
684
    const li = document.createElement("li");
685
    li.textContent = `${agent.name} (${agent.role}) — ${agent.blurb}`;
686
    list.append(li);
687
  }
688
}
689
690
requestAnimationFrame(frame);
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts