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

forked from benjivers/val

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

Keyboard shortcuts