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

forked from benjivers/val

workbenchthis machine
Files a07c6a614323699908d79cb29213e15bff012908
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
"use strict";
8
9
const $ = (id) => document.getElementById(id);
10
const view = $("view");
11
const ctx = view.getContext("2d");
12
13
const KEY_SLOT = { KeyC: "c", KeyQ: "q", KeyE: "e", KeyX: "x" };
14
const AGENT_COLOUR = { ember: "#f08a4b", veil: "#8f7fe8", sable: "#4fd6a0", mender: "#e0c060", rook: "#d96a8f" };
15
16
let map = null;
17
let snap = null;
18
let socket = null;
19
const shown = new Map(); // smoothed positions, so 15 frames a second look like 60
20
const keys = new Set();
21
let pointer = null; // aim target in canvas pixels
22
let firing = false;
23
let stick = null; // { id, ox, oy, mx, my }
24
let aimTouch = null;
25
26
// ------------------------------------------------------------------ socket
27
28
function connect() {
29
  const url = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/ws";
30
  socket = new WebSocket(url);
31
  socket.onopen = () => ($("conn").textContent = "live");
32
  socket.onclose = () => {
33
    $("conn").textContent = "reconnecting…";
34
    setTimeout(connect, 1500);
35
  };
36
  socket.onmessage = (event) => {
37
    const data = JSON.parse(event.data);
38
    if (data.t === "s") {
39
      snap = data;
40
      paintHud(data);
41
    }
42
  };
43
}
44
45
const send = (message) => {
46
  if (socket && socket.readyState === 1) socket.send(JSON.stringify(message));
47
};
48
49
// ------------------------------------------------------------------- input
50
51
addEventListener("keydown", (event) => {
52
  if (event.target instanceof HTMLInputElement) return;
53
  if (event.code === "KeyB") {
54
    toggleBuy();
55
    event.preventDefault();
56
    return;
57
  }
58
  if (KEY_SLOT[event.code]) {
59
    send({ t: "i", c: KEY_SLOT[event.code] });
60
    event.preventDefault();
61
    return;
62
  }
63
  keys.add(event.code);
64
  if (["KeyW", "KeyA", "KeyS", "KeyD", "Space", "KeyF", "KeyR"].includes(event.code)) event.preventDefault();
65
});
66
addEventListener("keyup", (event) => keys.delete(event.code));
67
addEventListener("blur", () => keys.clear());
68
69
view.addEventListener("pointermove", (event) => {
70
  if (event.pointerType === "touch") return;
71
  const box = view.getBoundingClientRect();
72
  pointer = { x: ((event.clientX - box.left) / box.width) * view.width, y: ((event.clientY - box.top) / box.height) * view.height };
73
});
74
view.addEventListener("pointerdown", (event) => {
75
  view.setPointerCapture(event.pointerId);
76
  const box = view.getBoundingClientRect();
77
  const x = event.clientX - box.left;
78
  if (event.pointerType === "touch") {
79
    if (x < box.width / 2) stick = { id: event.pointerId, ox: event.clientX, oy: event.clientY, mx: 0, my: 0 };
80
    else {
81
      aimTouch = { id: event.pointerId, x: ((event.clientX - box.left) / box.width) * view.width, y: ((event.clientY - box.top) / box.height) * view.height };
82
      firing = true;
83
    }
84
    event.preventDefault();
85
    return;
86
  }
87
  pointer = { x: ((event.clientX - box.left) / box.width) * view.width, y: ((event.clientY - box.top) / box.height) * view.height };
88
  firing = true;
89
});
90
view.addEventListener("pointermove", (event) => {
91
  if (stick && event.pointerId === stick.id) {
92
    const dx = event.clientX - stick.ox;
93
    const dy = event.clientY - stick.oy;
94
    const len = Math.hypot(dx, dy) || 1;
95
    const reach = Math.min(len, 48) / 48;
96
    stick.mx = (dx / len) * reach;
97
    stick.my = (dy / len) * reach;
98
  }
99
  if (aimTouch && event.pointerId === aimTouch.id) {
100
    const box = view.getBoundingClientRect();
101
    aimTouch.x = ((event.clientX - box.left) / box.width) * view.width;
102
    aimTouch.y = ((event.clientY - box.top) / box.height) * view.height;
103
  }
104
});
105
const endPointer = (event) => {
106
  if (stick && event.pointerId === stick.id) stick = null;
107
  else if (aimTouch && event.pointerId === aimTouch.id) {
108
    aimTouch = null;
109
    firing = false;
110
  } else firing = false;
111
};
112
view.addEventListener("pointerup", endPointer);
113
view.addEventListener("pointercancel", endPointer);
114
view.addEventListener("contextmenu", (event) => event.preventDefault());
115
116
// The buy opens itself every buy phase; closing it is remembered until the
117
// next one, so nobody has to fight the panel to watch the round start.
118
function toggleBuy() {
119
  const open = $("buy").classList.toggle("on");
120
  $("buy").dataset.closed = open ? "" : "1";
121
}
122
$("buytoggle").addEventListener("click", toggleBuy);
123
for (const button of document.querySelectorAll("[data-buy]"))
124
  button.addEventListener("click", () => send({ t: "buy", what: button.dataset.buy }));
125
for (const button of document.querySelectorAll("[data-agent]"))
126
  button.addEventListener("click", () => send({ t: "agent", id: button.dataset.agent }));
127
128
// Twenty times a second the server hears what this person is doing.
129
setInterval(() => {
130
  if (!snap || !map) return;
131
  let mx = 0;
132
  let my = 0;
133
  if (keys.has("KeyW")) my -= 1;
134
  if (keys.has("KeyS")) my += 1;
135
  if (keys.has("KeyA")) mx -= 1;
136
  if (keys.has("KeyD")) mx += 1;
137
  if (stick) {
138
    mx = stick.mx;
139
    my = stick.my;
140
  }
141
  const aim = aimTouch || pointer;
142
  const me = worldToScreen(snap.me);
143
  const angle = aim ? Math.atan2(aim.y - me.y, aim.x - me.x) : snap.me.angle;
144
  send({
145
    t: "i",
146
    mx,
147
    my,
148
    a: angle,
149
    f: firing || keys.has("Space"),
150
    u: keys.has("KeyF"),
151
    r: keys.has("KeyR"),
152
  });
153
}, 50);
154
155
// ------------------------------------------------------------------ drawing
156
157
function resize() {
158
  const box = view.getBoundingClientRect();
159
  const dpr = Math.min(devicePixelRatio || 1, 2);
160
  view.width = Math.round(box.width * dpr);
161
  view.height = Math.round(box.height * dpr);
162
}
163
addEventListener("resize", resize);
164
165
const scale = () => (map ? view.width / (map.rows[0].length * map.tile) : 1);
166
const worldToScreen = (p) => ({ x: p.x * scale(), y: p.y * scale() });
167
168
function smooth(id, x, y, angle) {
169
  const now = shown.get(id);
170
  if (!now) {
171
    shown.set(id, { x, y, angle });
172
    return { x, y, angle };
173
  }
174
  now.x += (x - now.x) * 0.35;
175
  now.y += (y - now.y) * 0.35;
176
  let turn = ((angle - now.angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI;
177
  now.angle += turn * 0.4;
178
  return now;
179
}
180
181
function drawMap(s) {
182
  const t = map.tile * s;
183
  for (let row = 0; row < map.rows.length; row++)
184
    for (let col = 0; col < map.rows[row].length; col++) {
185
      const cell = map.rows[row][col];
186
      ctx.fillStyle =
187
        cell === "#" ? "#20242d" : cell === "A" || cell === "B" ? "#2c2a22" : cell === "a" ? "#2a2024" : cell === "d" ? "#1e2630" : "#171a21";
188
      ctx.fillRect(col * t, row * t, t + 0.6, t + 0.6);
189
      if (cell === "A" || cell === "B") {
190
        ctx.fillStyle = "#6b5f3a";
191
        ctx.font = `${Math.round(t * 0.5)}px system-ui`;
192
        ctx.fillText(cell, col * t + t * 0.3, row * t + t * 0.68);
193
      }
194
    }
195
}
196
197
function drawZone(z, s) {
198
  const x = z.x * s;
199
  const y = z.y * s;
200
  const r = z.r * s;
201
  ctx.save();
202
  if (z.k === "smoke") {
203
    ctx.fillStyle = "rgba(190,195,205,0.72)";
204
    ctx.beginPath();
205
    ctx.arc(x, y, r, 0, 7);
206
    ctx.fill();
207
  } else if (z.k === "molly") {
208
    ctx.fillStyle = "rgba(230,110,50,0.45)";
209
    ctx.strokeStyle = "#f08a4b";
210
    ctx.beginPath();
211
    ctx.arc(x, y, r, 0, 7);
212
    ctx.fill();
213
    ctx.stroke();
214
  } else if (z.k === "wall") {
215
    ctx.fillStyle = "rgba(120,200,255,0.55)";
216
    ctx.beginPath();
217
    ctx.arc(x, y, r, 0, 7);
218
    ctx.fill();
219
  } else if (z.k === "flash") {
220
    ctx.fillStyle = z.f > 0 ? "#ffe9a8" : "rgba(255,255,255,0.9)";
221
    ctx.beginPath();
222
    ctx.arc(x, y, z.f > 0 ? 5 * s : 30 * s, 0, 7);
223
    ctx.fill();
224
  } else if (z.k === "dart") {
225
    ctx.strokeStyle = "rgba(90,220,170,0.7)";
226
    ctx.beginPath();
227
    ctx.arc(x, y, r, 0, 7);
228
    ctx.stroke();
229
  } else if (z.k === "trip") {
230
    ctx.strokeStyle = "rgba(230,200,90,0.8)";
231
    ctx.beginPath();
232
    ctx.arc(x, y, r, 0, 7);
233
    ctx.stroke();
234
  } else if (z.k === "heal") {
235
    ctx.fillStyle = "rgba(120,230,140,0.5)";
236
    ctx.beginPath();
237
    ctx.arc(x, y, 14 * s, 0, 7);
238
    ctx.fill();
239
  }
240
  ctx.restore();
241
}
242
243
function drawPlayer(p, s, me) {
244
  const pos = smooth(p.id, p.x, p.y, p.angle);
245
  const x = pos.x * s;
246
  const y = pos.y * s;
247
  const r = 16 * s;
248
  const mine = p.id === me.id;
249
  ctx.fillStyle = p.mate ? (mine ? "#eaeaea" : "#4fb3d9") : "#e35d5b";
250
  if (!p.alive) ctx.fillStyle = "#555a63";
251
  ctx.beginPath();
252
  ctx.arc(x, y, r, 0, 7);
253
  ctx.fill();
254
  ctx.strokeStyle = AGENT_COLOUR[p.agent] || "#888";
255
  ctx.lineWidth = Math.max(1, 3 * s);
256
  ctx.stroke();
257
  if (p.alive) {
258
    ctx.strokeStyle = mine ? "#fff" : ctx.fillStyle;
259
    ctx.beginPath();
260
    ctx.moveTo(x, y);
261
    ctx.lineTo(x + Math.cos(pos.angle) * r * 2, y + Math.sin(pos.angle) * r * 2);
262
    ctx.stroke();
263
  }
264
  if (p.spike) {
265
    ctx.fillStyle = "#f0d060";
266
    ctx.fillRect(x - r * 0.4, y - r * 1.8, r * 0.8, r * 0.8);
267
  }
268
  if (p.blind) {
269
    ctx.fillStyle = "rgba(255,255,255,0.8)";
270
    ctx.beginPath();
271
    ctx.arc(x, y - r * 1.6, r * 0.35, 0, 7);
272
    ctx.fill();
273
  }
274
  if (!mine) {
275
    ctx.fillStyle = "rgba(235,235,235,0.75)";
276
    ctx.font = `${Math.round(11 * Math.max(1, s * 2))}px system-ui`;
277
    ctx.textAlign = "center";
278
    ctx.fillText(p.name, x, y - r * 1.9);
279
    ctx.textAlign = "left";
280
  }
281
  if (p.alive && p.hp < 100) {
282
    ctx.fillStyle = "#0008";
283
    ctx.fillRect(x - r, y + r * 1.2, r * 2, 3);
284
    ctx.fillStyle = "#6fcf6f";
285
    ctx.fillRect(x - r, y + r * 1.2, r * 2 * (p.hp / 100), 3);
286
  }
287
}
288
289
function frame() {
290
  requestAnimationFrame(frame);
291
  if (!map) return;
292
  const s = scale();
293
  ctx.clearRect(0, 0, view.width, view.height);
294
  drawMap(s);
295
  if (!snap) return;
296
297
  for (const orb of snap.orbs) {
298
    ctx.fillStyle = "#c9a0ff";
299
    ctx.beginPath();
300
    ctx.arc(orb.x * s, orb.y * s, 9 * s, 0, 7);
301
    ctx.fill();
302
  }
303
  for (const zone of snap.zones) if (zone.k !== "smoke") drawZone(zone, s);
304
305
  if (snap.spike.x !== null) {
306
    const x = snap.spike.x * s;
307
    const y = snap.spike.y * s;
308
    ctx.fillStyle = snap.spike.planted ? (Math.floor(Date.now() / 250) % 2 ? "#ff5b4a" : "#8a2a22") : "#c8b45c";
309
    ctx.fillRect(x - 7 * s, y - 7 * s, 14 * s, 14 * s);
310
    if (snap.spike.planted && snap.spike.defuse > 0) {
311
      ctx.fillStyle = "#4fd6a0";
312
      ctx.fillRect(x - 20 * s, y + 12 * s, 40 * s * (snap.spike.defuse / 7), 4);
313
    }
314
  }
315
316
  for (const shot of snap.shots) {
317
    ctx.strokeStyle = shot.hit ? "rgba(255,120,110,0.85)" : "rgba(255,240,180,0.55)";
318
    ctx.lineWidth = Math.max(1, 2 * s);
319
    ctx.beginPath();
320
    ctx.moveTo(shot.x1 * s, shot.y1 * s);
321
    ctx.lineTo(shot.x2 * s, shot.y2 * s);
322
    ctx.stroke();
323
  }
324
325
  for (const player of snap.players) drawPlayer(player, s, snap.me);
326
  // Smoke goes on last: it hides what is behind it on screen too.
327
  for (const zone of snap.zones) if (zone.k === "smoke") drawZone(zone, s);
328
329
  if (snap.me.planting > 0) bar(snap.me.planting / 4, "#e0a040", "planting");
330
  else if (snap.spike.planted && snap.spike.defuse > 0 && snap.me.side === "def") bar(snap.spike.defuse / 7, "#4fd6a0", "defusing");
331
  $("flash").style.opacity = Math.min(0.95, snap.me.blind * 0.8);
332
  $("dead").style.display = snap.me.alive ? "none" : "grid";
333
}
334
335
function bar(fraction, colour, label) {
336
  const w = view.width * 0.4;
337
  const x = view.width / 2 - w / 2;
338
  const y = view.height * 0.86;
339
  ctx.fillStyle = "#0009";
340
  ctx.fillRect(x, y, w, 16);
341
  ctx.fillStyle = colour;
342
  ctx.fillRect(x, y, w * Math.min(1, fraction), 16);
343
  ctx.fillStyle = "#fff";
344
  ctx.font = "12px system-ui";
345
  ctx.textAlign = "center";
346
  ctx.fillText(label, view.width / 2, y + 12);
347
  ctx.textAlign = "left";
348
}
349
350
// --------------------------------------------------------------------- hud
351
352
const KITS = {};
353
354
function paintHud(s) {
355
  $("clock").textContent = Math.ceil(s.phase === "live" && s.spike.planted ? s.spike.fuse : s.clock);
356
  document.querySelector("#scoreline .red").textContent = "red " + s.score.red;
357
  document.querySelector("#scoreline .blue").textContent = s.score.blue + " blue";
358
  $("phase").textContent =
359
    `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 : ""}`;
360
  $("hp").textContent = `${s.me.hp} hp${s.me.armour ? " +" + s.me.armour : ""}`;
361
  $("gun").textContent = `${s.me.weapon}${s.me.reloading ? " reloading" : " " + s.me.ammo}`;
362
  $("creds").textContent = `${s.me.credits} cr`;
363
  $("ultmeter").textContent = `ult ${s.me.ult}/${s.me.ultNeeds}`;
364
  $("buy").classList.toggle("on", s.phase === "buy" && $("buy").dataset.closed !== "1");
365
  if (s.phase !== "buy") $("buy").dataset.closed = "";
366
367
  const kit = $("kit");
368
  const shape = ["c", "q", "e", "x"].map((slot) => `${slot}${s.me.charges[slot]}${Math.ceil(s.me.cooldown[slot])}`).join("|") + s.me.agent + s.me.ult;
369
  if (KITS.shape !== shape) {
370
    KITS.shape = shape;
371
    kit.innerHTML = "";
372
    for (const slot of ["c", "q", "e", "x"]) {
373
      const button = document.createElement("button");
374
      const ready = slot === "x" ? s.me.ult >= s.me.ultNeeds : s.me.charges[slot] > 0 && s.me.cooldown[slot] <= 0;
375
      button.disabled = !ready;
376
      const count = slot === "x" ? `${s.me.ult}/${s.me.ultNeeds}` : s.me.cooldown[slot] > 0 ? `${Math.ceil(s.me.cooldown[slot])}s` : s.me.charges[slot];
377
      button.innerHTML = `${slot.toUpperCase()} <b>${count}</b>`;
378
      button.type = "button";
379
      button.addEventListener("click", () => send({ t: "i", c: slot }));
380
      kit.append(button);
381
    }
382
  }
383
384
  const log = $("log");
385
  const text = s.events.slice(0, 3).join("\n");
386
  if (log.dataset.text !== text) {
387
    log.dataset.text = text;
388
    log.innerHTML = "";
389
    for (const line of s.events.slice(0, 3)) {
390
      const div = document.createElement("div");
391
      div.textContent = line;
392
      log.append(div);
393
    }
394
  }
395
396
  for (const side of ["atk", "def"]) {
397
    const body = $(side + "board");
398
    const rows = s.board.filter((p) => p.side === side).sort((a, b) => b.kills - a.kills);
399
    const key = rows.map((p) => `${p.name}${p.kills}${p.deaths}${p.alive}${p.credits}`).join();
400
    if (body.dataset.key === key) continue;
401
    body.dataset.key = key;
402
    body.innerHTML = "";
403
    for (const player of rows) {
404
      const tr = document.createElement("tr");
405
      const cells = [
406
        player.name + (player.bot ? " (bot)" : "") + (player.alive ? "" : " ✕"),
407
        player.agent + (player.credits !== null ? ` · ${player.credits}cr` : ""),
408
        String(player.kills),
409
        String(player.deaths),
410
      ];
411
      for (const value of cells) {
412
        const td = document.createElement("td");
413
        td.textContent = value;
414
        tr.append(td);
415
      }
416
      if (player.id === s.me.id) tr.style.fontWeight = "700";
417
      body.append(tr);
418
    }
419
  }
420
}
421
422
fetch("/map.json")
423
  .then((r) => r.json())
424
  .then((data) => {
425
    map = data;
426
    resize();
427
    connect();
428
  });
429
requestAnimationFrame(frame);
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts