wren/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Merge pull request #13 from wren/12-a-spectator-s-snapshot-has-no-me-and-the
A spectator's snapshot has no `me`, and the client throws on it
8 files
changed +338
−29
README.md +4 −1
| @@ -80,7 +80,10 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator. | ||
| 80 | 80 | again — cannot buy a second one on the other team. A second machine on a |
| 81 | 81 | second network is still a second person as far as the server can tell, |
| 82 | 82 | the same as any anonymous game without accounts; a socket that does not |
| 83 | get a seat is sent a spectator view with no position in it, never a side. | |
| 83 | get a seat is sent a spectator view with no position in it, never a side: | |
| 84 | the page draws the score, the round, the clock and the kill feed, says | |
| 85 | which of the two reasons it is watching for, and draws no map and no | |
| 86 | bodies, because it is never told where anybody is. | |
| 84 | 87 | - No overtime — first to 13 ends it at 12–12 too. |
| 85 | 88 | - Sight is a full circle rather than a view cone — you are drawn what is in |
| 86 | 89 | front of you, but the server reveals anything you have line of sight to, |
src/arena.ts +24 −7
| @@ -174,12 +174,20 @@ export const seatCount = (): number => seats.size; | ||
| 174 | 174 | |
| 175 | 175 | // A connection that never carried a session the page minted gets no side and |
| 176 | 176 | // no seat: only the public state everyone already sees on the page itself. |
| 177 | const spectators = new Map<string, (data: string) => void>(); | |
| 177 | const spectators = new Map<string, { send: (data: string) => void; why: Why }>(); | |
| 178 | 178 | |
| 179 | export function spectatorSnapshot(): string { | |
| 179 | /** | |
| 180 | * Why this connection is watching. The client says so in words, and the two | |
| 181 | * have different answers — open the tab that is playing, or let the page keep | |
| 182 | * its cookie — so which one it is travels with the snapshot. | |
| 183 | */ | |
| 184 | export type Why = "address" | "cookie" | "unsaid"; | |
| 185 | ||
| 186 | export function spectatorSnapshot(why: Why = "unsaid"): string { | |
| 180 | 187 | return JSON.stringify({ |
| 181 | 188 | t: "s", |
| 182 | 189 | spectator: true, |
| 190 | why, | |
| 183 | 191 | phase: state.phase, |
| 184 | 192 | clock: round2(Math.max(0, state.clock)), |
| 185 | 193 | round: state.round, |
| @@ -187,12 +195,21 @@ export function spectatorSnapshot(): string { | ||
| 187 | 195 | attackers: state.attackers, |
| 188 | 196 | result: state.result, |
| 189 | 197 | events: state.events.slice(0, 5).map((e) => e.text), |
| 198 | // Names and a gun, the same as a seat is sent, and for the same reason it | |
| 199 | // is safe there: the feed says what happened, never where. | |
| 200 | feed: feed.map((f) => ({ | |
| 201 | k: f.killer, | |
| 202 | v: f.victim, | |
| 203 | w: f.weapon ? WEAPONS[f.weapon].name : null, | |
| 204 | h: f.headshot, | |
| 205 | t: round2(Math.max(0, state.now - f.at)), | |
| 206 | })), | |
| 190 | 207 | }); |
| 191 | 208 | } |
| 192 | 209 | |
| 193 | export function watch(token: string, send: (data: string) => void): void { | |
| 194 | spectators.set(token, send); | |
| 195 | send(spectatorSnapshot()); | |
| 210 | export function watch(token: string, send: (data: string) => void, why: Why = "unsaid"): void { | |
| 211 | spectators.set(token, { send, why }); | |
| 212 | send(spectatorSnapshot(why)); | |
| 196 | 213 | } |
| 197 | 214 | |
| 198 | 215 | export function unwatch(token: string): void { |
| @@ -400,9 +417,9 @@ function step(): void { | ||
| 400 | 417 | // handler gives the seat back. |
| 401 | 418 | } |
| 402 | 419 | } |
| 403 | for (const send of spectators.values()) { | |
| 420 | for (const spectator of spectators.values()) { | |
| 404 | 421 | try { |
| 405 | send(spectatorSnapshot()); | |
| 422 | spectator.send(spectatorSnapshot(spectator.why)); | |
| 406 | 423 | } catch { |
| 407 | 424 | // Same story: the close handler drops it from the set. |
| 408 | 425 | } |
src/client.test.ts +33 −1
| @@ -347,10 +347,42 @@ test("the bundled client connects, paints the HUD and draws a frame", async () = | ||
| 347 | 347 | expect(seat.x).toBe(startedAt.x); |
| 348 | 348 | expect(seat.y).toBe(startedAt.y); |
| 349 | 349 | arena.startArena(); |
| 350 | }, 15000); | |
| 351 | ||
| 352 | test("a snapshot with no seat in it draws the watching view instead of throwing", async () => { | |
| 353 | // The shape no other test covers: score, round, clock and feed, and no `me` | |
| 354 | // and no players at all. The client used to reach straight into `snap.me` | |
| 355 | // on the way in, so a second tab's first frame threw and the page stopped | |
| 356 | // drawing. This is the server's own spectator snapshot, handed to the | |
| 357 | // bundled client the way the socket hands it over. | |
| 358 | const { spectatorSnapshot } = await import("./arena.ts"); | |
| 359 | const deliver = (why: "address" | "cookie") => | |
| 360 | openedSocket!.onmessage!({ data: spectatorSnapshot(why) } as MessageEvent); | |
| 361 | ||
| 362 | drawn.length = 0; | |
| 363 | deliver("address"); | |
| 364 | runFrame(); | |
| 365 | ||
| 366 | // It says what the match is doing, and why this connection is watching. | |
| 367 | expect(String(nodes.get("phase")!.textContent)).toContain("round"); | |
| 368 | expect(String(nodes.get("hp")!.textContent)).toBe("watching"); | |
| 369 | expect(String(nodes.get("prompt")!.textContent)).toContain("address"); | |
| 370 | // Nothing on screen that would need a position to fill in. | |
| 371 | expect((nodes.get("kit")!.children as unknown[]).length).toBe(0); | |
| 372 | expect((nodes.get("roster")!.children as unknown[]).length).toBe(0); | |
| 373 | expect((nodes.get("atkboard")!.children as unknown[]).length).toBe(0); | |
| 374 | // And the canvas is words on a panel, not a map: one rectangle, no tiles. | |
| 375 | expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBe(1); | |
| 376 | expect(drawn.filter((op) => op.startsWith("fillText")).length).toBeGreaterThan(3); | |
| 377 | ||
| 378 | // The other reason reads differently, and the frame after it still draws. | |
| 379 | deliver("cookie"); | |
| 380 | runFrame(); | |
| 381 | expect(String(nodes.get("prompt")!.textContent)).toContain("cookie"); | |
| 350 | 382 | |
| 351 | 383 | // This test's loopback address is shared with every other test in the |
| 352 | 384 | // run; close the socket so the address is free for the next real |
| 353 | 385 | // connection rather than left holding a seat for the rest of the suite. |
| 354 | 386 | openedSocket?.close(); |
| 355 | 387 | await Bun.sleep(50); |
| 356 | }, 15000); | |
| 388 | }); |
src/server.test.ts +14 −1
| @@ -281,11 +281,24 @@ test("a real second connection from one address gets a spectator view, positions | ||
| 281 | 281 | wsTwo.onmessage = (event) => resolve(JSON.parse(String(event.data))); |
| 282 | 282 | wsTwo.onerror = reject; |
| 283 | 283 | }); |
| 284 | expect(snapTwo).toMatchObject({ t: "s", spectator: true }); | |
| 284 | // It says why it has no seat, because the two reasons have different | |
| 285 | // answers, and it carries the kill feed — names and a gun, never a place. | |
| 286 | expect(snapTwo).toMatchObject({ t: "s", spectator: true, why: "address" }); | |
| 287 | expect(Array.isArray((snapTwo as { feed?: unknown[] }).feed)).toBe(true); | |
| 285 | 288 | // No coordinate anywhere, not under "me", not under a different key either. |
| 286 | 289 | expect(JSON.stringify(snapTwo)).not.toMatch(/"x"|"y"/); |
| 290 | ||
| 291 | // And a connection carrying no session at all is told the other reason. | |
| 292 | const wsThree = new WebSocket(wsUrl); | |
| 293 | const snapThree = await new Promise<unknown>((resolve, reject) => { | |
| 294 | wsThree.onmessage = (event) => resolve(JSON.parse(String(event.data))); | |
| 295 | wsThree.onerror = reject; | |
| 296 | }); | |
| 297 | expect(snapThree).toMatchObject({ spectator: true, why: "cookie" }); | |
| 298 | ||
| 287 | 299 | wsOne.close(); |
| 288 | 300 | wsTwo.close(); |
| 301 | wsThree.close(); | |
| 289 | 302 | await Bun.sleep(50); |
| 290 | 303 | }); |
| 291 | 304 |
src/server.ts +11 −5
| @@ -23,6 +23,7 @@ import { | ||
| 23 | 23 | unwatch, |
| 24 | 24 | watch, |
| 25 | 25 | type Command, |
| 26 | type Why, | |
| 26 | 27 | } from "./arena.ts"; |
| 27 | 28 | import { recentResults } from "./db.ts"; |
| 28 | 29 | import { gridRows, TILE } from "./map.ts"; |
| @@ -158,7 +159,7 @@ const cleanName = (raw: unknown): string => { | ||
| 158 | 159 | const setCookie = (name: string, value: string, httpOnly: boolean): string => |
| 159 | 160 | `${name}=${encodeURIComponent(value)}; Path=/; SameSite=Lax; Max-Age=604800${httpOnly ? "; HttpOnly" : ""}${publicOrigin.startsWith("https:") ? "; Secure" : ""}`; |
| 160 | 161 | |
| 161 | type SocketData = { session: string; playerId: string; address: string; token: string; spectator: boolean }; | |
| 162 | type SocketData = { session: string; playerId: string; address: string; token: string; spectator: boolean; why: Why }; | |
| 162 | 163 | |
| 163 | 164 | // ---------------------------------------------------------------- routing |
| 164 | 165 | |
| @@ -201,13 +202,18 @@ export const server = Bun.serve<SocketData, Record<string, never>>({ | ||
| 201 | 202 | if (!sameOrigin(request)) return new Response("forbidden", { status: 403, headers: SECURITY }); |
| 202 | 203 | const token = randomUUID(); |
| 203 | 204 | if (fresh || addressCapped(address, session)) { |
| 204 | const upgraded = self.upgrade(request, { data: { session, playerId: "", address, token, spectator: true } }); | |
| 205 | // Which of the two it is: the watching page says so in words, and the | |
| 206 | // answer differs — open the tab that plays, or keep the cookie. | |
| 207 | const why: Why = fresh ? "cookie" : "address"; | |
| 208 | const upgraded = self.upgrade(request, { data: { session, playerId: "", address, token, spectator: true, why } }); | |
| 205 | 209 | return upgraded ? undefined : new Response("expected a websocket", { status: 426, headers: SECURITY }); |
| 206 | 210 | } |
| 207 | 211 | const name = cleanName(jar[NAME]) || `Guest-${session.slice(0, 4)}`; |
| 208 | 212 | const player = takeSeat(address, session, name); |
| 209 | 213 | if (!player) return new Response("no seats", { status: 503, headers: SECURITY }); |
| 210 | const upgraded = self.upgrade(request, { data: { session, playerId: player.id, address, token, spectator: false } }); | |
| 214 | const upgraded = self.upgrade(request, { | |
| 215 | data: { session, playerId: player.id, address, token, spectator: false, why: "unsaid" }, | |
| 216 | }); | |
| 211 | 217 | return upgraded ? undefined : new Response("expected a websocket", { status: 426, headers: SECURITY }); |
| 212 | 218 | } |
| 213 | 219 | |
| @@ -291,8 +297,8 @@ export const server = Bun.serve<SocketData, Record<string, never>>({ | ||
| 291 | 297 | websocket: { |
| 292 | 298 | maxPayloadLength: 4 * 1024, |
| 293 | 299 | open(socket) { |
| 294 | const { session, playerId, address, token, spectator } = socket.data; | |
| 295 | if (spectator) return void watch(token, (data) => socket.send(data)); | |
| 300 | const { session, playerId, address, token, spectator, why } = socket.data; | |
| 301 | if (spectator) return void watch(token, (data) => socket.send(data), why); | |
| 296 | 302 | attach(session, playerId, address, token, (data) => socket.send(data)); |
| 297 | 303 | socket.send(snapshot(playerId)); |
| 298 | 304 | }, |
src/watch.test.ts added +92
| @@ -0,0 +1,92 @@ | ||
| 1 | /** | |
| 2 | * The watching view: what a snapshot with no seat in it turns into on screen. | |
| 3 | */ | |
| 4 | ||
| 5 | import { describe, expect, test } from "bun:test"; | |
| 6 | import { drawWatching, watchLines, watching, wrap } from "../static/watch.js"; | |
| 7 | ||
| 8 | const spectator = (over: Record<string, unknown> = {}) => ({ | |
| 9 | t: "s", | |
| 10 | spectator: true, | |
| 11 | why: "address", | |
| 12 | phase: "live", | |
| 13 | clock: 42.4, | |
| 14 | round: 7, | |
| 15 | score: { red: 3, blue: 3 }, | |
| 16 | attackers: "red", | |
| 17 | result: null, | |
| 18 | events: ["red won the round"], | |
| 19 | feed: [], | |
| 20 | ...over, | |
| 21 | }); | |
| 22 | ||
| 23 | const seated = () => ({ t: "s", phase: "live", me: { id: "p1", alive: true }, players: [] }); | |
| 24 | ||
| 25 | describe("telling a seat from a watcher", () => { | |
| 26 | test("a spectator snapshot is watching, a seat's is not", () => { | |
| 27 | expect(watching(spectator())).toBe(true); | |
| 28 | expect(watching(seated())).toBe(false); | |
| 29 | }); | |
| 30 | ||
| 31 | test("a snapshot with no `me` is watching even if nobody flagged it", () => { | |
| 32 | expect(watching({ t: "s", phase: "live" })).toBe(true); | |
| 33 | expect(watching(null)).toBe(false); | |
| 34 | expect(watching(undefined)).toBe(false); | |
| 35 | }); | |
| 36 | }); | |
| 37 | ||
| 38 | describe("what the watching view says", () => { | |
| 39 | test("the score, the round and the clock, from the snapshot it was given", () => { | |
| 40 | const lines = watchLines(spectator()); | |
| 41 | expect(lines.score).toBe("red 3 — 3 blue"); | |
| 42 | expect(lines.clock).toBe("43s"); | |
| 43 | expect(lines.phase).toBe("round 7 · live · red attacking"); | |
| 44 | }); | |
| 45 | ||
| 46 | test("the reason is named: the seat is held at this address", () => { | |
| 47 | expect(watchLines(spectator()).why).toContain("address"); | |
| 48 | expect(watchLines(spectator({ why: "cookie" })).why).toContain("cookie"); | |
| 49 | // A snapshot that does not say still gets a sentence, not `undefined`. | |
| 50 | expect(watchLines(spectator({ why: undefined })).why).toContain("watching"); | |
| 51 | }); | |
| 52 | ||
| 53 | test("it never claims to know where anybody is", () => { | |
| 54 | const lines = watchLines(spectator()); | |
| 55 | expect(lines.note).toContain("No map"); | |
| 56 | expect(JSON.stringify(lines)).not.toMatch(/"x"|"y"/); | |
| 57 | }); | |
| 58 | ||
| 59 | test("a half-built snapshot is drawn, not thrown on", () => { | |
| 60 | const lines = watchLines({ spectator: true }); | |
| 61 | expect(lines.score).toBe("red 0 — 0 blue"); | |
| 62 | expect(lines.clock).toBe("0s"); | |
| 63 | expect(lines.phase).toContain("round 1"); | |
| 64 | }); | |
| 65 | }); | |
| 66 | ||
| 67 | test("long sentences are broken to fit the canvas", () => { | |
| 68 | const ctx = { measureText: (text: string) => ({ width: text.length * 10 }) }; | |
| 69 | const lines = wrap(ctx, "one two three four five six", 100); | |
| 70 | expect(lines.length).toBeGreaterThan(1); | |
| 71 | for (const line of lines) expect(line.length).toBeLessThanOrEqual(10); | |
| 72 | expect(lines.join(" ")).toBe("one two three four five six"); | |
| 73 | }); | |
| 74 | ||
| 75 | test("drawing it fills a flat panel and writes the score, and draws no world", () => { | |
| 76 | const ops: string[] = []; | |
| 77 | const ctx = new Proxy( | |
| 78 | { measureText: (text: string) => ({ width: text.length * 6 }) } as Record<string, unknown>, | |
| 79 | { | |
| 80 | get: (target, prop: string) => | |
| 81 | prop in target | |
| 82 | ? target[prop] | |
| 83 | : (...args: unknown[]) => ops.push(`${prop}(${args.map(String).join(",")})`), | |
| 84 | set: () => true, | |
| 85 | }, | |
| 86 | ); | |
| 87 | drawWatching(ctx as never, spectator(), 900, 570); | |
| 88 | // One background rectangle and nothing else with an edge: no tiles, no bodies. | |
| 89 | expect(ops.filter((op) => op.startsWith("fillRect")).length).toBe(1); | |
| 90 | expect(ops.some((op) => op.startsWith("fillText(red 3 — 3 blue"))).toBe(true); | |
| 91 | expect(ops.some((op) => op.startsWith("fillText(watching"))).toBe(true); | |
| 92 | }); |
static/val.js +69 −14
| @@ -21,6 +21,7 @@ import { roster, rosterKey } from "./roster.js"; | ||
| 21 | 21 | import { AGENT_COLOUR, drawScene, makeScene } from "./scene.js"; |
| 22 | 22 | import { moveVector } from "./steer.js"; |
| 23 | 23 | import { drawTopdown } from "./topdown.js"; |
| 24 | import { drawWatching, watching, watchLines } from "./watch.js"; | |
| 24 | 25 | import { aim as aimAt, makeYaw, settle, told, turn } from "./yaw.js"; |
| 25 | 26 | |
| 26 | 27 | const $ = (id) => document.getElementById(id); |
| @@ -87,6 +88,9 @@ function connect() { | ||
| 87 | 88 | if (data.t !== "s") return; |
| 88 | 89 | snap = data; |
| 89 | 90 | snapAt = performance.now(); |
| 91 | // No seat, no `me`, no players. Everything below reads a body out of the | |
| 92 | // snapshot, so a watching connection takes the other path entirely. | |
| 93 | if (watching(data)) return void paintWatch(data); | |
| 90 | 94 | // Timed samples for every body in it, ours included: the drawing walks |
| 91 | 95 | // between them rather than chasing the newest one. |
| 92 | 96 | arrive(timeline, snapAt / 1000); |
| @@ -162,6 +166,7 @@ addEventListener("blur", () => { | ||
| 162 | 166 | // look, Escape gives it back. The angle sent is the same angle as ever. |
| 163 | 167 | view.addEventListener("click", () => { |
| 164 | 168 | beeper.wake(); |
| 169 | if (watching(snap)) return; // nothing to aim: capturing the mouse would be a lie | |
| 165 | 170 | if (mode !== "fp" || locked) return; |
| 166 | 171 | if (!view.requestPointerLock) return setMode("top"); // no lock, no first person |
| 167 | 172 | // Asked for too soon after the last release, a browser rejects this; that |
| @@ -275,7 +280,7 @@ function readIntent() { | ||
| 275 | 280 | // Twenty times a second the server hears it. Intent only — a direction, an |
| 276 | 281 | // angle, a held key — never a position: the guess below is for drawing. |
| 277 | 282 | setInterval(() => { |
| 278 | if (!snap || !map) return; | |
| 283 | if (!snap || !map || watching(snap)) return; // nothing to drive, nothing to send | |
| 279 | 284 | const now = readIntent(); |
| 280 | 285 | send({ t: "i", mx: now.mx, my: now.my, a: now.angle, f: now.fire, u: now.use, r: now.reload }); |
| 281 | 286 | // What we told it, so its echo is recognisable as ours when it comes back. |
| @@ -309,7 +314,8 @@ const look = (p) => (predicting() && p.id === snap.me.id ? drawnMe : bodyAt(time | ||
| 309 | 314 | * match, not of the map — and a guess that does not know it would spend the |
| 310 | 315 | * phase being pulled back through the door. |
| 311 | 316 | */ |
| 312 | const predicting = () => snap !== null && world !== null && snap.phase !== "buy" && snap.me.alive; | |
| 317 | const predicting = () => | |
| 318 | snap !== null && !watching(snap) && world !== null && snap.phase !== "buy" && snap.me.alive; | |
| 313 | 319 | |
| 314 | 320 | /** The server's answer, caught up to now, then this frame of your own keys. */ |
| 315 | 321 | function predict(dt, now) { |
| @@ -347,6 +353,9 @@ function frame(now) { | ||
| 347 | 353 | const dt = Math.min(0.1, (now - last) / 1000); |
| 348 | 354 | last = now; |
| 349 | 355 | drawAt = renderTime(timeline, now / 1000); |
| 356 | // A watching connection draws the watching view and stops there: it has no | |
| 357 | // map to stand on and no body to put on it, and must invent neither. | |
| 358 | if (watching(snap)) return void drawWatch(); | |
| 350 | 359 | if (!map) return; |
| 351 | 360 | gunTick(gun, dt); |
| 352 | 361 | ctx.clearRect(0, 0, view.width, view.height); |
| @@ -486,18 +495,7 @@ function paintHud(s) { | ||
| 486 | 495 | paintBuy(s); |
| 487 | 496 | paintRoster(s); |
| 488 | 497 | paintFeed(s); |
| 489 | ||
| 490 | const log = $("log"); | |
| 491 | const lines = s.events.slice(0, 3).join("\n"); | |
| 492 | if (log.dataset.text !== lines) { | |
| 493 | log.dataset.text = lines; | |
| 494 | log.replaceChildren(); | |
| 495 | for (const line of s.events.slice(0, 3)) { | |
| 496 | const div = document.createElement("div"); | |
| 497 | div.textContent = line; | |
| 498 | log.append(div); | |
| 499 | } | |
| 500 | } | |
| 498 | paintLog(s); | |
| 501 | 499 | |
| 502 | 500 | for (const side of ["atk", "def"]) { |
| 503 | 501 | const body = $(side + "board"); |
| @@ -525,6 +523,63 @@ function paintHud(s) { | ||
| 525 | 523 | } |
| 526 | 524 | } |
| 527 | 525 | |
| 526 | /** The last few things that happened, in words. Rebuilt only when they change. */ | |
| 527 | function paintLog(s) { | |
| 528 | const log = $("log"); | |
| 529 | const lines = (s.events ?? []).slice(0, 3); | |
| 530 | if (log.dataset.text === lines.join("\n")) return; | |
| 531 | log.dataset.text = lines.join("\n"); | |
| 532 | log.replaceChildren(); | |
| 533 | for (const line of lines) { | |
| 534 | const div = document.createElement("div"); | |
| 535 | div.textContent = line; | |
| 536 | log.append(div); | |
| 537 | } | |
| 538 | } | |
| 539 | ||
| 540 | /** | |
| 541 | * The HUD for a connection with no seat. Everything a seat's HUD says about | |
| 542 | * you — health, gun, credits, kit, your five — has no answer here, so it says | |
| 543 | * so rather than leaving the page's first paint standing there going stale. | |
| 544 | */ | |
| 545 | function paintWatch(s) { | |
| 546 | const lines = watchLines(s); | |
| 547 | $("clock").textContent = Math.ceil(Math.max(0, Number(s.clock) || 0)); | |
| 548 | document.querySelector("#scoreline .red").textContent = "red " + (s.score?.red ?? 0); | |
| 549 | document.querySelector("#scoreline .blue").textContent = (s.score?.blue ?? 0) + " blue"; | |
| 550 | $("phase").textContent = lines.phase; | |
| 551 | $("hp").textContent = "watching"; | |
| 552 | $("gun").textContent = "no seat"; | |
| 553 | $("creds").textContent = "—"; | |
| 554 | $("ultmeter").textContent = "—"; | |
| 555 | $("buy").classList.toggle("on", false); | |
| 556 | if ($("prompt").textContent !== lines.why) $("prompt").textContent = lines.why; | |
| 557 | paintFeed(s); | |
| 558 | paintLog(s); | |
| 559 | blank($("kit")); | |
| 560 | blank($("roster")); | |
| 561 | blank($("atkboard")); | |
| 562 | blank($("defboard")); | |
| 563 | if (KITS.shape !== "watching") KITS.shape = "watching"; | |
| 564 | } | |
| 565 | ||
| 566 | /** Empty a panel whose answer this connection is not sent, once. */ | |
| 567 | function blank(panel) { | |
| 568 | if (panel.dataset.key === "watching") return; | |
| 569 | panel.dataset.key = "watching"; | |
| 570 | panel.dataset.text = "watching"; | |
| 571 | panel.replaceChildren(); | |
| 572 | } | |
| 573 | ||
| 574 | /** The canvas, watching: no map, no bodies, and the overlays a seat owns off. */ | |
| 575 | function drawWatch() { | |
| 576 | ctx.clearRect(0, 0, view.width, view.height); | |
| 577 | drawWatching(ctx, snap, view.width, view.height); | |
| 578 | $("flash").style.opacity = 0; | |
| 579 | $("dead").style.display = "none"; | |
| 580 | $("aimhint").style.display = "none"; | |
| 581 | } | |
| 582 | ||
| 528 | 583 | /** Is this player standing where the spike arms? The grid says so. */ |
| 529 | 584 | function onSite(me) { |
| 530 | 585 | if (!map) return false; |
static/watch.js added +91
| @@ -0,0 +1,91 @@ | ||
| 1 | /** | |
| 2 | * Watching. | |
| 3 | * | |
| 4 | * A connection the server gave no seat — one that carried no session the page | |
| 5 | * minted, or a second client from an address that already holds a seat — is | |
| 6 | * sent a snapshot with no `me` and no players: the score, the round, the | |
| 7 | * clock and the kill feed, and nothing at all that says where anybody is. | |
| 8 | * That is the whole point of it, so this view draws no map and no bodies. It | |
| 9 | * says what the match is doing and why this connection is only watching. | |
| 10 | */ | |
| 11 | ||
| 12 | /** Is this a snapshot for a connection that has no seat? */ | |
| 13 | export const watching = (snap) => Boolean(snap) && (snap.spectator === true || !snap.me); | |
| 14 | ||
| 15 | // Why the server handed this connection no seat. It says which, because the | |
| 16 | // two have different answers: open the tab that is playing, or let the page | |
| 17 | // keep its cookie. | |
| 18 | const WHY = { | |
| 19 | address: "Somebody at this address is already playing — that tab holds the seat, and this one watches.", | |
| 20 | cookie: "This browser did not send the session cookie the page sets, so there is no seat to give it.", | |
| 21 | }; | |
| 22 | const WHY_UNSAID = "This connection has no seat, so it is watching the match."; | |
| 23 | ||
| 24 | const PHASE = { buy: "buy phase", live: "live", end: "round over" }; | |
| 25 | ||
| 26 | /** What the watching view says, in order. Plain strings, nothing drawn. */ | |
| 27 | export function watchLines(snap) { | |
| 28 | const score = snap.score ?? { red: 0, blue: 0 }; | |
| 29 | return { | |
| 30 | title: "watching", | |
| 31 | why: WHY[snap.why] ?? WHY_UNSAID, | |
| 32 | phase: `round ${snap.round ?? 1} · ${PHASE[snap.phase] ?? "match over"} · ${snap.attackers ?? "—"} attacking${snap.result ? " · " + snap.result : ""}`, | |
| 33 | score: `red ${score.red ?? 0} — ${score.blue ?? 0} blue`, | |
| 34 | clock: `${Math.ceil(Math.max(0, Number(snap.clock) || 0))}s`, | |
| 35 | note: "No map and no bodies: a watching connection is never told where anybody is.", | |
| 36 | }; | |
| 37 | } | |
| 38 | ||
| 39 | /** Break a line at the last space that still fits, measured by the canvas. */ | |
| 40 | export function wrap(ctx, text, maxWidth) { | |
| 41 | const lines = []; | |
| 42 | let line = ""; | |
| 43 | for (const word of String(text).split(" ")) { | |
| 44 | const tried = line ? `${line} ${word}` : word; | |
| 45 | if (line && ctx.measureText(tried).width > maxWidth) { | |
| 46 | lines.push(line); | |
| 47 | line = word; | |
| 48 | } else line = tried; | |
| 49 | } | |
| 50 | if (line) lines.push(line); | |
| 51 | return lines; | |
| 52 | } | |
| 53 | ||
| 54 | /** | |
| 55 | * The view itself: the score, the round, the clock, and the sentence that | |
| 56 | * says why this connection is watching. Centred on a flat panel, because | |
| 57 | * there is no world here to stand it on. | |
| 58 | */ | |
| 59 | export function drawWatching(ctx, snap, width, height) { | |
| 60 | const lines = watchLines(snap); | |
| 61 | const mid = width / 2; | |
| 62 | const unit = Math.min(width / 900, height / 570); | |
| 63 | ctx.fillStyle = "#0b0d11"; | |
| 64 | ctx.fillRect(0, 0, width, height); | |
| 65 | ctx.textAlign = "center"; | |
| 66 | ||
| 67 | ctx.fillStyle = "#8b93a5"; | |
| 68 | ctx.font = `${Math.round(22 * unit)}px system-ui, sans-serif`; | |
| 69 | ctx.fillText(lines.title, mid, height * 0.3); | |
| 70 | ||
| 71 | ctx.fillStyle = "#e7e9ee"; | |
| 72 | ctx.font = `${Math.round(54 * unit)}px system-ui, sans-serif`; | |
| 73 | ctx.fillText(lines.score, mid, height * 0.42); | |
| 74 | ||
| 75 | ctx.font = `${Math.round(30 * unit)}px system-ui, sans-serif`; | |
| 76 | ctx.fillText(lines.clock, mid, height * 0.52); | |
| 77 | ||
| 78 | ctx.fillStyle = "#b9c0cf"; | |
| 79 | ctx.font = `${Math.round(19 * unit)}px system-ui, sans-serif`; | |
| 80 | ctx.fillText(lines.phase, mid, height * 0.6); | |
| 81 | ||
| 82 | ctx.fillStyle = "#8b93a5"; | |
| 83 | ctx.font = `${Math.round(17 * unit)}px system-ui, sans-serif`; | |
| 84 | let y = height * 0.7; | |
| 85 | for (const line of [...wrap(ctx, lines.why, width * 0.8), ...wrap(ctx, lines.note, width * 0.8)]) { | |
| 86 | ctx.fillText(line, mid, y); | |
| 87 | y += 24 * unit; | |
| 88 | } | |
| 89 | ctx.textAlign = "left"; | |
| 90 | return lines; | |
| 91 | } |