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

forked from benjivers/val

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

Keyboard shortcuts