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

forked from benjivers/val

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

Keyboard shortcuts