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

forked from benjivers/val

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

Keyboard shortcuts