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

forked from benjivers/val

workbenchthis machine

Merge pull request #4 from wren/3-put-the-player-inside-the-map-a-first-pe

Put the player inside the map: a first-person view, and instructions somebody can follow
benjivers committed 607c661 merge Browse files
27 files changed +2974 −247
README.md +30 −12
@@ -2,11 +2,12 @@
22
33 Five attackers with the spike, five defenders, abilities on cooldown. First to 13.
44
5 A top-down 5v5 played in a browser. One match is always running on the
6 server: open the page and you are given a seat in the round that is
7 happening, taken from a bot where it stands. When a match ends it shows the
8 result for a few seconds and starts a new one, so nobody ever arrives at a
9 frozen board.
5 A 5v5 played in a browser, from inside the player's head — the map is a tile
6 grid and the view is a raycaster over it, so there is no 3D library and no
7 dependency at all. One match is always running on the server: open the page
8 and you are given a seat in the round that is happening, taken from a bot
9 where it stands. When a match ends it shows the result for a few seconds and
10 starts a new one, so nobody ever arrives at a frozen board.
1011
1112 ## What is real
1213
@@ -31,6 +32,10 @@ server:
3132 - **Credits.** 800 to start, 9,000 cap, +200 a kill, +300 to the team on a
3233 plant, loss bonus 1,900 / 2,400 / 2,900. A buy you cannot afford is
3334 refused server-side.
35 - **Who is alive.** The roster under the view lists your five with their
36 health from the snapshot's own team data, and the kill feed is derived in
37 `src/arena.ts` by comparing the players either side of a tick — it carries
38 names, the gun and an age, and never a position.
3439 - **Bots** fill every empty seat and use their kit: smokes on the way in,
3540 flashes before the entry, a recon dart, a molly on the planted spike.
3641
@@ -42,20 +47,30 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator.
4247
4348 ## What is not
4449
50 - The view is a raycaster: walls are one tile tall and flat-shaded, there is
51 no looking up or down, and everything that is not a wall — bodies, the
52 spike, ability zones — is a flat billboard scaled by distance and clipped
53 against the walls.
4554 - One map, one match at a time, ten seats. There is no matchmaking, no
4655 ranking and no account: your seat belongs to a session cookie.
4756 - No overtime — first to 13 ends it at 12–12 too.
48 - Sight is a full circle rather than a view cone; top-down has no vertical,
49 so the hit location comes from how precisely the shot passes the target.
57 - Sight is a full circle rather than a view cone — you are drawn what is in
58 front of you, but the server reveals anything you have line of sight to,
59 so a team mate behind you still appears on the minimap. The world has no
60 vertical, so the hit location comes from how precisely the shot passes
61 the target.
5062 - Assists are counted as a field but never awarded.
5163 - The live match lives in memory and is gone if the process restarts; only
5264 finished match results are written to SQLite under `FORGE_APP_DATA`.
5365
5466 ## Playing
5567
56 WASD move, the mouse aims, click fires, **R** reloads, **F** plants and
57 defuses (hold it), **C / Q / E / X** cast, **B** opens the buy. On a phone
58 the left thumb walks and the right thumb aims and fires.
68 Click the view to capture the mouse, then WASD move and the mouse looks;
69 **Esc** gives the pointer back. Click fires, **R** reloads, **F** plants and
70 defuses (hold it), **C / Q / E / X** cast, **B** opens the buy, **Tab**
71 holds the big map open, **V** swaps to the top-down view and **H** or **?**
72 brings back the how-to-play card. On a phone it stays top-down: the left
73 thumb walks and the right thumb aims and fires.
5974
6075 ```sh
6176 PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here
@@ -77,6 +92,9 @@ says how.
7792 `src/economy.ts` credits · `src/abilities.ts` the agent kits ·
7893 `src/match.ts` the round loop (`tick(state, dt)` is the whole game and takes
7994 no clock) · `src/bots.ts` the seats nobody is in · `src/arena.ts` the one
80 live match and its sockets · `src/server.ts` the door · `static/val.js` the
81 browser. The rules are pure functions and the tests play whole matches
95 live match and its sockets · `src/server.ts` the door · `static/` the browser client —
96 `val.js` wires the socket, the keys and the HUD, `raycast.js` is the ray
97 maths, `scene.js` the first-person view, `topdown.js` the minimap,
98 `gun.js`, `beep.js`, `steer.js`, `roster.js` and `prompt.js` the rest; the server bundles
99 them into one content-named file at start-up. The rules are pure functions and the tests play whole matches
82100 headlessly.
src/arena.ts +60 −3
@@ -13,6 +13,7 @@
1313 import { agentById, sightBlocked } from "./abilities.ts";
1414 import { runBots } from "./bots.ts";
1515 import { recordResult } from "./db.ts";
16 import { fallsBetween, marksOf, trimFeed, type Fall, type Mark } from "./killfeed.ts";
1617 import type { Point } from "./map.ts";
1718 import {
1819 buyAbility,
@@ -25,7 +26,7 @@ import {
2526 visible,
2627 } from "./match.ts";
2728 import type { MatchState, Player } from "./state.ts";
28 import { isWeaponId } from "./weapons.ts";
29 import { isWeaponId, WEAPONS } from "./weapons.ts";
2930
3031 export const TICK = 1 / 30;
3132 const BROADCAST_EVERY = 2;
@@ -36,17 +37,40 @@ let state: MatchState = newMatch();
3637 const seats = new Map<string, Seat>();
3738 let ticks = 0;
3839 let lastResult: string | null = null;
40 // The kill feed is read off the match a tick at a time; the rules keep no
41 // list of their own, and this is a view, not a rule.
42 let marks: Mark[] = marksOf(state.players);
43 let feed: Fall[] = [];
3944
4045 export const match = (): MatchState => state;
4146
4247 export const findPlayer = (id: string): Player | undefined => state.players.find((p) => p.id === id);
4348
49 /**
50 * A name nobody else in this match is using.
51 *
52 * The kill feed, the round log and the scoreboard all address people by
53 * name: two players called Kit make a line that reads as a team kill this
54 * game cannot have. The bots are dealt distinct names; a person who picks
55 * one that is taken gets a number after it, inside the sixteen characters a
56 * name is allowed.
57 */
58 function freeName(want: string, exceptId: string): string {
59 const taken = new Set(state.players.filter((p) => p.id !== exceptId).map((p) => p.name));
60 if (!taken.has(want)) return want;
61 for (let n = 2; n < 100; n++) {
62 const tried = `${want.slice(0, 13)} ${n}`;
63 if (!taken.has(tried)) return tried;
64 }
65 return want;
66 }
67
4468 /** Sit somebody down in the emptiest team, replacing a bot where it stands. */
4569 export function takeSeat(session: string, name: string): Player | null {
4670 const existing = seats.get(session);
4771 const held = existing ? findPlayer(existing.playerId) : undefined;
4872 if (held) {
49 held.name = name;
73 held.name = freeName(name, held.id);
5074 return held;
5175 }
5276 const humans = { red: 0, blue: 0 };
@@ -56,7 +80,7 @@ export function takeSeat(session: string, name: string): Player | null {
5680 state.players.find((p) => p.bot && p.team === wanted) ?? state.players.find((p) => p.bot);
5781 if (!seat) return null;
5882 seat.bot = false;
59 seat.name = name;
83 seat.name = freeName(name, seat.id);
6084 seat.think = 0;
6185 seat.input = { mx: 0, my: 0, angle: seat.angle, fire: false, use: false, reload: false, cast: null };
6286 return seat;
@@ -211,14 +235,47 @@ export function snapshot(viewerId: string): string {
211235 ultNeeds: agentById(p.agent).abilities.x.points,
212236 })),
213237 events: state.events.slice(0, 5).map((e) => e.text),
238 // Names and a gun, never a position: the feed says what happened, not
239 // where, so it can never be read as a call-out.
240 feed: feed.map((f) => ({
241 k: f.killer,
242 v: f.victim,
243 w: f.weapon ? WEAPONS[f.weapon].name : null,
244 h: f.headshot,
245 t: round2(Math.max(0, state.now - f.at)),
246 })),
214247 });
215248 }
216249
250 /**
251 * Who fell on this tick. A shot that landed this tick and started where the
252 * killer stands is what tells a gun kill from an ability kill — the position
253 * is used here and goes no further.
254 */
255 function readFalls(): void {
256 const after = marksOf(state.players);
257 const shooters = new Set<string>();
258 for (const shot of state.shots) {
259 if (!shot.hit || shot.age > 0) continue;
260 for (const player of state.players)
261 if (Math.hypot(player.x - shot.x1, player.y - shot.y1) < 2) shooters.add(player.id);
262 }
263 const falls = fallsBetween(marks, after, {
264 at: state.now,
265 events: state.events.slice(0, 6).map((e) => e.text),
266 shooters,
267 });
268 marks = after;
269 if (falls.length) feed = [...feed, ...falls];
270 feed = trimFeed(feed, state.now);
271 }
272
217273 // ----------------------------------------------------------------- the loop
218274
219275 function step(): void {
220276 runBots(state, TICK);
221277 tick(state, TICK);
278 readFalls();
222279 if (state.result && state.result !== lastResult) {
223280 lastResult = state.result;
224281 recordResult(state.result, state.score.red, state.score.blue, state.round);
src/beep.test.ts added +24
@@ -0,0 +1,24 @@
1 import { expect, test } from "bun:test";
2 import { beepInterval, beepPitch, makeBeeper } from "../static/beep.js";
3
4 test("the spike beeps faster as the fuse burns down", () => {
5 expect(beepInterval(45)).toBeGreaterThan(beepInterval(20));
6 expect(beepInterval(20)).toBeGreaterThan(beepInterval(5));
7 expect(beepInterval(1)).toBeGreaterThanOrEqual(0.12);
8 expect(beepInterval(45)).toBeLessThanOrEqual(1);
9 });
10
11 test("a spent fuse does not beep at all", () => {
12 expect(beepInterval(0)).toBe(0);
13 });
14
15 test("and it rises in pitch on the way", () => {
16 expect(beepPitch(2)).toBeGreaterThan(beepPitch(40));
17 });
18
19 test("nothing is played before the browser has had a gesture", () => {
20 const beeper = makeBeeper();
21 // No AudioContext in this runtime: update must be a no-op, not a throw.
22 expect(() => beeper.update(true, 30, 1)).not.toThrow();
23 expect(() => beeper.wake()).not.toThrow();
24 });
src/client.test.ts added +259
@@ -0,0 +1,259 @@
1 /**
2 * The client, played.
3 *
4 * The bundle the browser is served is loaded here against a stub document
5 * and a canvas that only writes down what it was told to draw, and pointed
6 * at the running server over a real socket. It is not a browser, but it is
7 * the whole path a person takes: connect, take a snapshot, paint the HUD,
8 * draw a frame, press a key, send an input back.
9 */
10
11 import { expect, test } from "bun:test";
12 import { mkdtempSync } from "node:fs";
13 import { tmpdir } from "node:os";
14 import { join } from "node:path";
15
16 process.env.FORGE_APP_DATA ??= mkdtempSync(join(tmpdir(), "app-"));
17 process.env.PORT ??= "0";
18 const { server } = await import("./server.ts");
19 const base = `http://${server.hostname}:${server.port}`;
20
21 // ------------------------------------------------------------ the stub DOM
22
23 const drawn: string[] = [];
24 const listeners = new Map<string, ((event: unknown) => void)[]>();
25 const frames: ((now: number) => void)[] = [];
26
27 const noop = () => {};
28 const context = () =>
29 new Proxy(
30 {
31 measureText: (text: string) => ({ width: text.length * 6 }),
32 createLinearGradient: () => ({ addColorStop: noop }),
33 } as Record<string, unknown>,
34 {
35 get(target, prop: string) {
36 if (prop in target) return target[prop];
37 if (prop === "fillStyle" || prop === "strokeStyle" || prop === "font" || prop === "textAlign") return "";
38 if (prop === "globalAlpha" || prop === "lineWidth") return 1;
39 return (...args: unknown[]) => drawn.push(`${prop}(${args.length})`);
40 },
41 set: () => true,
42 },
43 );
44
45 function element(id: string, attrs: Record<string, string> = {}) {
46 const node: Record<string, unknown> = {
47 id,
48 dataset: { ...attrs },
49 style: {},
50 textContent: "",
51 title: "",
52 disabled: false,
53 type: "",
54 children: [] as unknown[],
55 classList: {
56 on: new Set<string>(),
57 add(name: string) {
58 (this.on as Set<string>).add(name);
59 },
60 remove(name: string) {
61 (this.on as Set<string>).delete(name);
62 },
63 toggle(name: string, force?: boolean) {
64 const on = force ?? !(this.on as Set<string>).has(name);
65 if (on) (this.on as Set<string>).add(name);
66 else (this.on as Set<string>).delete(name);
67 return on;
68 },
69 contains(name: string) {
70 return (this.on as Set<string>).has(name);
71 },
72 },
73 events: new Map<string, ((event: unknown) => void)[]>(),
74 addEventListener(kind: string, fn: (event: unknown) => void) {
75 const map = node.events as Map<string, ((event: unknown) => void)[]>;
76 map.set(kind, [...(map.get(kind) ?? []), fn]);
77 },
78 append(...kids: unknown[]) {
79 (node.children as unknown[]).push(...kids);
80 },
81 replaceChildren(...kids: unknown[]) {
82 node.children = kids;
83 },
84 getBoundingClientRect: () => ({ left: 0, top: 0, width: 900, height: 570 }),
85 getContext: () => context(),
86 requestPointerLock: noop,
87 setPointerCapture: noop,
88 width: 900,
89 height: 570,
90 };
91 return node;
92 }
93
94 const click = (node: Record<string, unknown>) => {
95 for (const fn of (node.events as Map<string, ((e: unknown) => void)[]>).get("click") ?? []) fn({});
96 };
97 const press = (code: string, key = "") => {
98 for (const fn of listeners.get("keydown") ?? []) fn({ code, key, target: {}, preventDefault: noop });
99 };
100
101 const nodes = new Map<string, Record<string, unknown>>();
102 for (const id of [
103 "view", "conn", "clock", "phase", "hp", "gun", "creds", "ultmeter", "kit", "buy", "buytoggle",
104 "viewtoggle", "helptoggle", "helpclose", "help", "prompt", "flash", "dead", "aimhint", "stage",
105 "log", "atkboard", "defboard", "helpagents", "roster", "feed",
106 ])
107 nodes.set(id, element(id));
108
109 const buys = [
110 element("b1", { buy: "vandal", cost: "2900" }),
111 element("b2", { buy: "c", cost: "0" }),
112 element("b3", { buy: "q", cost: "0" }),
113 ];
114 const agentButtons = [element("a1", { agent: "veil" })];
115 const scoreRed = element("red");
116 const scoreBlue = element("blue");
117
118 Object.assign(globalThis, {
119 document: {
120 getElementById: (id: string) => nodes.get(id) ?? null,
121 querySelector: (sel: string) => (sel.includes("red") ? scoreRed : scoreBlue),
122 querySelectorAll: (sel: string) => (sel.includes("data-buy") ? buys : agentButtons),
123 createElement: (tag: string) => element(tag),
124 addEventListener: noop,
125 pointerLockElement: null,
126 exitPointerLock: noop,
127 },
128 HTMLInputElement: class {},
129 matchMedia: () => ({ matches: false }),
130 devicePixelRatio: 1,
131 localStorage: {
132 store: new Map<string, string>(),
133 getItem(key: string) {
134 return this.store.get(key) ?? null;
135 },
136 setItem(key: string, value: string) {
137 this.store.set(key, value);
138 },
139 },
140 requestAnimationFrame: (fn: (now: number) => void) => frames.push(fn),
141 addEventListener: (kind: string, fn: (event: unknown) => void) => {
142 listeners.set(kind, [...(listeners.get(kind) ?? []), fn]);
143 },
144 location: new URL(base),
145 });
146
147 // Relative fetches, the way a page makes them.
148 const realFetch = globalThis.fetch;
149 globalThis.fetch = ((input: string, init?: RequestInit) =>
150 realFetch(typeof input === "string" && input.startsWith("/") ? base + input : input, init)) as typeof fetch;
151
152 const runFrame = () => {
153 const pending = frames.splice(0, frames.length);
154 for (const fn of pending) fn(performance.now());
155 };
156 const until = async (what: () => boolean, ms = 4000) => {
157 const stop = Date.now() + ms;
158 while (Date.now() < stop) {
159 runFrame();
160 if (what()) return true;
161 await Bun.sleep(20);
162 }
163 return false;
164 };
165
166 // ------------------------------------------------------------------ the run
167
168 test("the bundled client connects, paints the HUD and draws a frame", async () => {
169 const page = await (await fetch(base + "/")).text();
170 const src = /src="(\/static\/val\.[0-9a-f]+\.js)"/.exec(page)![1]!;
171 const script = await (await fetch(base + src)).text();
172 const path = join(process.env.FORGE_APP_DATA!, "client.mjs");
173 await Bun.write(path, script);
174
175 await import(path);
176 expect(await until(() => nodes.get("conn")!.textContent === "live")).toBe(true);
177 expect(await until(() => String(nodes.get("hp")!.textContent).includes("hp"))).toBe(true);
178
179 // The HUD says what the match says.
180 expect(String(nodes.get("phase")!.textContent)).toContain("round");
181 expect(String(nodes.get("creds")!.textContent)).toContain("cr");
182 // The kit is four buttons, each named after the ability it casts.
183 expect((nodes.get("kit")!.children as unknown[]).length).toBe(4);
184 // One prompt, in words, about the round that is running.
185 expect(String(nodes.get("prompt")!.textContent).length).toBeGreaterThan(8);
186 // And the buy panel prices itself off the agent's real kit.
187 expect(String(buys[1]!.title)).toContain("charge");
188
189 // The team is on screen whether anybody is in sight or not: five slots.
190 const slots = nodes.get("roster")!.children as { className: string }[];
191 expect(slots.length).toBe(5);
192 expect(slots.filter((slot) => slot.className.includes("mine")).length).toBe(1);
193
194 // Run the match on a few seconds: bots trade, and the feed fills with
195 // names — and with nothing that says where anybody is.
196 const arena = await import("./arena.ts");
197 for (let i = 0; i < 40 && (nodes.get("feed")!.children as unknown[]).length === 0; i++) {
198 arena.advance(5);
199 await Bun.sleep(5);
200 }
201 const feedRows = nodes.get("feed")!.children as { children: { textContent: string }[] }[];
202 expect(feedRows.length).toBeGreaterThan(0);
203 // killer ▸ victim, and the gun or the ability it was: four spans, no numbers.
204 const row = feedRows[0]!.children.map((span) => span.textContent).join("");
205 expect(row).toContain(" ▸ ");
206 expect(row).not.toMatch(/\d/);
207
208 // A frame of the first-person view actually draws walls: a slice a column,
209 // far more rectangles than the map has tiles.
210 drawn.length = 0;
211 runFrame();
212 expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeGreaterThan(800);
213
214 // Tab swaps that frame for the whole map — many fewer rectangles.
215 for (const fn of listeners.get("keydown") ?? []) fn({ code: "Tab", key: "", target: {}, preventDefault: noop });
216 drawn.length = 0;
217 runFrame();
218 expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeLessThan(800);
219 for (const fn of listeners.get("keyup") ?? []) fn({ code: "Tab" });
220 drawn.length = 0;
221 runFrame();
222 expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeGreaterThan(800);
223
224 // Holding W reaches the server as a direction, and it is the way we face.
225 const seat = (await import("./arena.ts")).match().players.find((p) => !p.bot)!;
226 press("KeyW");
227 await Bun.sleep(160);
228 expect(Math.hypot(seat.input.mx, seat.input.my)).toBeGreaterThan(0.9);
229 expect(Math.atan2(seat.input.my, seat.input.mx)).toBeCloseTo(seat.input.angle, 4);
230
231 // V falls back to the top-down view, and it draws too.
232 press("KeyV");
233 drawn.length = 0;
234 runFrame();
235 expect(drawn.some((op) => op.startsWith("fillRect"))).toBe(true);
236 expect(nodes.get("stage")!.dataset).toMatchObject({ mode: "top" });
237 press("KeyV");
238 expect(nodes.get("stage")!.dataset).toMatchObject({ mode: "fp" });
239
240 // H toggles the card either way, and the button closes it for good.
241 const help = nodes.get("help")!.style as Record<string, string>;
242 const before = help.display;
243 press("KeyH");
244 expect(help.display).not.toBe(before);
245 if (help.display !== "grid") press("KeyH");
246 expect(help.display).toBe("grid");
247 click(nodes.get("helpclose")!);
248 expect((nodes.get("help")!.style as Record<string, string>).display).toBe("none");
249 expect((globalThis as unknown as { localStorage: Storage }).localStorage.getItem("val.help.seen")).toBe("1");
250
251 // B works the buy panel either way — it opens itself in the buy phase.
252 const panel = nodes.get("buy")!.classList as { contains: (n: string) => boolean };
253 const wasOpen = panel.contains("on");
254 press("KeyB");
255 expect(panel.contains("on")).toBe(!wasOpen);
256
257 // And the socket really took a seat in the running match.
258 expect(seat.bot).toBe(false);
259 }, 15000);
src/gun.test.ts added +49
@@ -0,0 +1,49 @@
1 import { expect, test } from "bun:test";
2 import { fade, firedNow, gunSnapshot, gunTick, hitMarked, makeGun } from "../static/gun.js";
3
4 const snap = (over: Record<string, unknown> = {}, shots: unknown[] = []) => ({
5 me: { x: 100, y: 100, ammo: 25, reloading: false, alive: true, ...over },
6 shots,
7 });
8
9 test("the magazine going down is a shot; a reload is not", () => {
10 expect(firedNow(25, 24, false)).toBe(true);
11 expect(firedNow(25, 25, false)).toBe(false);
12 expect(firedNow(1, 25, true)).toBe(false);
13 expect(firedNow(null, 25, false)).toBe(false);
14 });
15
16 test("a hit marker comes from a shot of mine the server says landed", () => {
17 const mine = [{ x1: 102, y1: 99, x2: 400, y2: 100, hit: true }];
18 expect(hitMarked(mine, { x: 100, y: 100 })).toBe(true);
19 const missed = [{ x1: 102, y1: 99, x2: 400, y2: 100, hit: false }];
20 expect(hitMarked(missed, { x: 100, y: 100 })).toBe(false);
21 const somebody_else = [{ x1: 900, y1: 900, x2: 400, y2: 100, hit: true }];
22 expect(hitMarked(somebody_else, { x: 100, y: 100 })).toBe(false);
23 });
24
25 test("a snapshot with a shot in it kicks the gun, and the kick fades", () => {
26 const gun = makeGun();
27 gunSnapshot(gun, snap());
28 expect(gun.kick).toBe(0);
29 gunSnapshot(gun, snap({ ammo: 24 }));
30 expect(gun.kick).toBe(1);
31 gunTick(gun, 0.06);
32 expect(gun.kick).toBeLessThan(1);
33 gunTick(gun, 1);
34 expect(gun.kick).toBe(0);
35 });
36
37 test("a landed shot marks, and the mark fades on its own clock", () => {
38 const gun = makeGun();
39 gunSnapshot(gun, snap({ ammo: 24 }, [{ x1: 100, y1: 100, x2: 300, y2: 100, hit: true }]));
40 expect(gun.mark).toBe(1);
41 gunTick(gun, 0.2);
42 expect(gun.mark).toBeGreaterThan(0);
43 gunTick(gun, 0.2);
44 expect(gun.mark).toBe(0);
45 });
46
47 test("fade never goes below nothing", () => {
48 expect(fade(0.1, 10, 0.2)).toBe(0);
49 });
src/killfeed.test.ts added +153
@@ -0,0 +1,153 @@
1 import { describe, expect, test } from "bun:test";
2 import { mkdtempSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { fallsBetween, marksOf, trimFeed, type Mark } from "./killfeed.ts";
6 import { newMatch } from "./match.ts";
7
8 process.env.FORGE_APP_DATA ??= mkdtempSync(join(tmpdir(), "app-"));
9
10 const mark = (id: string, over: Partial<Mark> = {}): Mark => ({
11 id,
12 name: id,
13 side: "atk",
14 alive: true,
15 kills: 0,
16 weapon: "vandal",
17 ...over,
18 });
19
20 const context = (events: string[] = [], shooters: string[] = [], at = 10) => ({
21 at,
22 events,
23 shooters: new Set(shooters),
24 });
25
26 describe("reading falls out of two instants", () => {
27 test("a shot kill names the killer, the victim and the gun", () => {
28 const before = [mark("A", { side: "atk" }), mark("B", { side: "def" })];
29 const after = [mark("A", { side: "atk", kills: 1 }), mark("B", { side: "def", alive: false })];
30 const falls = fallsBetween(before, after, context(["A killed B"], ["A"]));
31 expect(falls).toEqual([{ killer: "A", victim: "B", weapon: "vandal", headshot: false, at: 10 }]);
32 });
33
34 test("a head shot is marked as one", () => {
35 const before = [mark("A", { side: "atk" }), mark("B", { side: "def" })];
36 const after = [mark("A", { side: "atk", kills: 1 }), mark("B", { side: "def", alive: false })];
37 expect(fallsBetween(before, after, context(["A headshot B"], ["A"]))[0]!.headshot).toBe(true);
38 });
39
40 test("a kill with no shot behind it names no gun — a molly is not a Vandal", () => {
41 const before = [mark("A", { side: "atk" }), mark("B", { side: "def" })];
42 const after = [mark("A", { side: "atk", kills: 1 }), mark("B", { side: "def", alive: false })];
43 expect(fallsBetween(before, after, context(["A killed B"]))[0]!.weapon).toBe(null);
44 });
45
46 test("dying to nobody — a molly of your own, a fall — has no killer", () => {
47 const before = [mark("A", { side: "atk" }), mark("B", { side: "def" })];
48 const after = [mark("A", { side: "atk" }), mark("B", { side: "def", alive: false })];
49 const falls = fallsBetween(before, after, context(["B died"]));
50 expect(falls.length).toBe(1);
51 expect(falls[0]!.killer).toBe(null);
52 expect(falls[0]!.weapon).toBe(null);
53 });
54
55 test("two kills in one tick go to the right killers, by what the loop said", () => {
56 const before = [
57 mark("A", { side: "atk" }),
58 mark("C", { side: "atk" }),
59 mark("B", { side: "def" }),
60 mark("D", { side: "def" }),
61 ];
62 const after = [
63 mark("A", { side: "atk", kills: 1 }),
64 mark("C", { side: "atk", kills: 1 }),
65 mark("B", { side: "def", alive: false }),
66 mark("D", { side: "def", alive: false }),
67 ];
68 const falls = fallsBetween(before, after, context(["C killed B", "A killed D"], ["A", "C"]));
69 expect(falls.find((f) => f.victim === "B")!.killer).toBe("C");
70 expect(falls.find((f) => f.victim === "D")!.killer).toBe("A");
71 });
72
73 test("a killer is never somebody on the victim's own side", () => {
74 const before = [mark("A", { side: "atk" }), mark("B", { side: "atk" }), mark("E", { side: "def" })];
75 const after = [
76 mark("A", { side: "atk", kills: 1 }),
77 mark("B", { side: "atk", alive: false }),
78 mark("E", { side: "def" }),
79 ];
80 expect(fallsBetween(before, after, context([]))[0]!.killer).toBe(null);
81 });
82
83 test("a kill with two victims and one killer only credits the one kill", () => {
84 const before = [mark("A", { side: "atk" }), mark("B", { side: "def" }), mark("C", { side: "def" })];
85 const after = [
86 mark("A", { side: "atk", kills: 1 }),
87 mark("B", { side: "def", alive: false }),
88 mark("C", { side: "def", alive: false }),
89 ];
90 const falls = fallsBetween(before, after, context(["A killed B"], ["A"]));
91 expect(falls.filter((f) => f.killer === "A").length).toBe(1);
92 expect(falls.filter((f) => f.killer === null).length).toBe(1);
93 });
94
95 test("nothing happening is no falls", () => {
96 const players = [mark("A"), mark("B", { side: "def" })];
97 expect(fallsBetween(players, players, context())).toEqual([]);
98 });
99
100 test("a body that was already dead does not fall twice", () => {
101 const before = [mark("B", { side: "def", alive: false })];
102 expect(fallsBetween(before, before, context())).toEqual([]);
103 });
104
105 test("a fall never carries a position", () => {
106 const before = [mark("A", { side: "atk" }), mark("B", { side: "def" })];
107 const after = [mark("A", { side: "atk", kills: 1 }), mark("B", { side: "def", alive: false })];
108 const fall = fallsBetween(before, after, context(["A killed B"], ["A"]))[0]!;
109 expect(Object.keys(fall).sort()).toEqual(["at", "headshot", "killer", "victim", "weapon"]);
110 });
111
112 test("marks come off the real players of a real match", () => {
113 const marks = marksOf(newMatch().players);
114 expect(marks.length).toBe(10);
115 expect(marks.every((m) => m.alive && m.kills === 0 && typeof m.name === "string")).toBe(true);
116 expect(JSON.stringify(marks)).not.toContain('"x"');
117 });
118 });
119
120 describe("the feed as it is shown", () => {
121 const fall = (victim: string, at: number) => ({ killer: "A", victim, weapon: null, headshot: false, at });
122
123 test("keeps the last few and drops the stale", () => {
124 const feed = [fall("a", 1), fall("b", 2), fall("c", 9), fall("d", 10)];
125 expect(trimFeed(feed, 10, 5, 8).map((f) => f.victim)).toEqual(["b", "c", "d"]);
126 });
127
128 test("never grows past its slots", () => {
129 const feed = [1, 2, 3, 4, 5, 6, 7].map((n) => fall(String(n), 10));
130 expect(trimFeed(feed, 10, 5, 8).map((f) => f.victim)).toEqual(["3", "4", "5", "6", "7"]);
131 });
132 });
133
134 describe("the feed the arena sends", () => {
135 test("bots killing each other reach the snapshot as names, with no positions", async () => {
136 const { advance, match, snapshot } = await import("./arena.ts");
137 const viewer = match().players[0]!.id;
138 let feed: { k: string | null; v: string; w: string | null; h: boolean; t: number }[] = [];
139 for (let seconds = 0; seconds < 400 && feed.length === 0; seconds += 10) {
140 advance(10);
141 feed = JSON.parse(snapshot(viewer)).feed;
142 }
143 expect(feed.length).toBeGreaterThan(0);
144 const names = match().players.map((p) => p.name);
145 for (const line of feed) {
146 expect(names).toContain(line.v);
147 if (line.k !== null) expect(names).toContain(line.k);
148 expect(line.t).toBeLessThanOrEqual(8);
149 // Names, a gun, an age. Nothing that says where anybody is.
150 expect(Object.keys(line).sort()).toEqual(["h", "k", "t", "v", "w"]);
151 }
152 }, 30000);
153 });
src/killfeed.ts added +84
@@ -0,0 +1,84 @@
1 /**
2 * Who killed whom, read off the match rather than written into it.
3 *
4 * The round loop counts kills and deaths on the players and says a line
5 * about it; it keeps no list. Rather than change the rules to carry one,
6 * the arena compares the players before and after a tick: somebody who was
7 * alive and is not is a victim, somebody whose kill count went up is a
8 * killer, and the line the loop said settles which of them did it when two
9 * fall in the same thirtieth of a second.
10 *
11 * Pure functions over plain marks. Nothing here knows a position, and a fall
12 * never carries one — the feed must not be a way to learn where anybody is.
13 */
14
15 import type { Player, Side } from "./state.ts";
16 import type { WeaponId } from "./weapons.ts";
17
18 /** Everything about a player this module needs, taken at one instant. */
19 export type Mark = {
20 id: string;
21 name: string;
22 side: Side;
23 alive: boolean;
24 kills: number;
25 weapon: WeaponId;
26 };
27
28 export type Fall = {
29 killer: string | null;
30 victim: string;
31 /** The killer's gun when the kill came from a shot; null when it did not. */
32 weapon: WeaponId | null;
33 headshot: boolean;
34 /** The match clock when it happened. */
35 at: number;
36 };
37
38 export const marksOf = (players: Player[]): Mark[] =>
39 players.map((p) => ({ id: p.id, name: p.name, side: p.side, alive: p.alive, kills: p.kills, weapon: p.weapon }));
40
41 export type Context = {
42 at: number;
43 /** The lines the round loop said this tick, or the most recent few. */
44 events: string[];
45 /** Players whose shot landed on this tick, so a molly is not called a gun. */
46 shooters: Set<string>;
47 };
48
49 /**
50 * The falls between two instants. Killers are matched to victims by the line
51 * the loop said, and by side and a spare kill when there is no line.
52 */
53 export function fallsBetween(before: Mark[], after: Mark[], context: Context): Fall[] {
54 const was = new Map(before.map((m) => [m.id, m]));
55 const falls: Fall[] = [];
56 const credit = new Map<string, number>();
57 for (const mark of after) {
58 const old = was.get(mark.id);
59 if (old && mark.kills > old.kills) credit.set(mark.id, mark.kills - old.kills);
60 }
61 const named = (killer: Mark, victim: Mark): boolean =>
62 context.events.includes(`${killer.name} killed ${victim.name}`) ||
63 context.events.includes(`${killer.name} headshot ${victim.name}`);
64
65 for (const mark of after) {
66 const old = was.get(mark.id);
67 if (!old || !old.alive || mark.alive) continue;
68 const able = after.filter((m) => (credit.get(m.id) ?? 0) > 0 && m.side !== mark.side);
69 const killer = able.find((m) => named(m, mark)) ?? able[0] ?? null;
70 if (killer) credit.set(killer.id, (credit.get(killer.id) ?? 1) - 1);
71 falls.push({
72 killer: killer ? killer.name : null,
73 victim: mark.name,
74 weapon: killer && context.shooters.has(killer.id) ? killer.weapon : null,
75 headshot: killer ? context.events.includes(`${killer.name} headshot ${mark.name}`) : false,
76 at: context.at,
77 });
78 }
79 return falls;
80 }
81
82 /** The feed as it is shown: the last few falls, and only the recent ones. */
83 export const trimFeed = (feed: Fall[], now: number, keep = 5, life = 8): Fall[] =>
84 feed.filter((f) => now - f.at <= life).slice(-keep);
src/match.test.ts +26
@@ -8,6 +8,7 @@ import {
88 DEFUSE_CHECKPOINT,
99 DEFUSE_TIME,
1010 FUSE_TIME,
11 makePlayer,
1112 newMatch,
1213 PLANT_TIME,
1314 ROUND_TIME,
@@ -371,3 +372,28 @@ test("bots play the round: they buy, they take a site and they use their kit", (
371372 expect(state.players.some((p) => p.weapon !== "classic")).toBe(true);
372373 expect(aliveOn(state, "atk").length + aliveOn(state, "def").length).toBeGreaterThan(0);
373374 });
375
376
377 test("no match ever has two players with the same name", () => {
378 // The kill feed, the round log and the scoreboard all address people by
379 // name; two Kits read as a team kill this game cannot have. A thousand
380 // rosters, no collision.
381 for (let seed = 0; seed < 1000; seed++) {
382 const names = newMatch(seed).players.map((p) => p.name);
383 expect(names.length).toBe(10);
384 expect(new Set(names).size).toBe(10);
385 }
386 });
387
388 test("a seat made on its own still gets a name of its own", () => {
389 const state = newMatch(1234);
390 const names = new Set<string>();
391 for (const team of ["red", "blue"] as const)
392 for (let i = 0; i < 5; i++) names.add(makePlayer(state, team, i).name);
393 expect(names.size).toBe(10);
394 });
395
396 test("the deal is the seeded shuffle, so the same match replays the same", () => {
397 expect(newMatch(77).players.map((p) => p.name)).toEqual(newMatch(77).players.map((p) => p.name));
398 expect(newMatch(77).players.map((p) => p.name)).not.toEqual(newMatch(78).players.map((p) => p.name));
399 });
src/match.ts +35 −3
@@ -69,9 +69,39 @@ const BOT_NAMES = [
6969 "Kit", "Lux", "Mox", "Nell", "Orla", "Pike", "Quill", "Rio", "Sage", "Tove",
7070 ];
7171
72 export function makePlayer(state: MatchState, team: Team, index: number): Player {
72 /**
73 * Twenty names, shuffled, dealt to the ten seats — drawn without replacement.
74 *
75 * A name is how a person is addressed: in the kill feed, in the round log, on
76 * the scoreboard. Two bots called Kit make "Kit killed Kit", which reads as a
77 * team kill that this game cannot even have. Picking each name independently
78 * collided in most matches — a uniform pick over twenty names for ten seats
79 * is a birthday problem, and no arithmetic spacing survives adding a uniform
80 * term to it. Shuffling once and dealing cannot collide at all.
81 */
82 export function dealNames(state: MatchState, count = TEAMS.length * ROSTER): string[] {
83 const bag = BOT_NAMES.slice();
84 for (let i = bag.length - 1; i > 0; i--) {
85 const j = Math.floor(random(state) * (i + 1));
86 const swap = bag[i]!;
87 bag[i] = bag[j]!;
88 bag[j] = swap;
89 }
90 return bag.slice(0, Math.min(count, bag.length));
91 }
92
93 /**
94 * One seat. The name is dealt by the caller so a whole roster is drawn
95 * without replacement; on its own it falls back to the seat's own slot,
96 * which is distinct for every seat in a match too.
97 */
98 export function makePlayer(
99 state: MatchState,
100 team: Team,
101 index: number,
102 name = BOT_NAMES[(TEAMS.indexOf(team) * ROSTER + index) % BOT_NAMES.length]!,
103 ): Player {
73104 const agent = ["ember", "veil", "sable", "mender", "rook"][index % 5]!;
74 const name = BOT_NAMES[(TEAMS.indexOf(team) * 7 + index * 3 + Math.floor(random(state) * 20)) % BOT_NAMES.length]!;
75105 return {
76106 id: `bot-${team}-${index}`,
77107 name: `${name}`,
@@ -132,8 +162,10 @@ export function newMatch(seed = Date.now() % 100000): MatchState {
132162 seed,
133163 now: 0,
134164 };
165 const names = dealNames(state);
135166 for (const team of TEAMS)
136 for (let i = 0; i < ROSTER; i++) state.players.push(makePlayer(state, team, i));
167 for (let i = 0; i < ROSTER; i++)
168 state.players.push(makePlayer(state, team, i, names[state.players.length]));
137169 startRound(state, true);
138170 say(state, "Round 1 — buy phase");
139171 return state;
src/pages/game.ts +83 −8
@@ -70,6 +70,41 @@ export function gamePage(opts: {
7070 #flash { position: absolute; inset: 0; background: #fff; opacity: 0; pointer-events: none; }
7171 #dead { position: absolute; inset: 0; display: none; place-items: center; background: #0008;
7272 font-size: 1.1rem; letter-spacing: .1em; text-transform: uppercase; }
73 #prompt { position: absolute; left: 50%; transform: translateX(-50%); bottom: .6rem;
74 max-width: 92%; padding: .35rem .7rem; border-radius: .4rem; background: #0b0d11cc;
75 border: 1px solid #2f3541; font-size: .9rem; text-align: center; pointer-events: none; }
76 #aimhint { position: absolute; inset: auto 0 45% 0; display: none; place-items: center;
77 font-size: .85rem; letter-spacing: .08em; text-transform: uppercase;
78 text-shadow: 0 1px 4px #000; pointer-events: none; }
79 #help { position: fixed; inset: 0; z-index: 10; display: none; place-items: center;
80 background: #080a0ef2; padding: .8rem; overflow: auto; }
81 #help > div { max-width: 40rem; }
82 #help h2 { font-size: 1rem; margin: 0 0 .4rem; letter-spacing: .1em; text-transform: uppercase; }
83 #help p, #help ul { margin: .4rem 0; font-size: .88rem; }
84 #help ul { padding-left: 1.1rem; }
85 #help kbd { background: #222732; border: 1px solid #39404e; border-radius: .25rem;
86 padding: 0 .3rem; font: inherit; font-size: .82rem; }
87 #helpclose { min-height: 44px; padding: 0 1.1rem; margin-top: .5rem; background: #3a4356;
88 color: var(--ink); border: 0; border-radius: .45rem; font: inherit; cursor: pointer; }
89 #roster { display: grid; grid-template-columns: repeat(5, 1fr); gap: .35rem; margin: .5rem 0;
90 min-height: 3.1rem; }
91 .slot { background: #171a21; border: 1px solid #262b35; border-left: 3px solid #4a5162;
92 border-radius: .35rem; padding: .2rem .35rem; font-size: .72rem; overflow: hidden; }
93 .slot .who { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
94 .slot .agent { display: block; opacity: .6; font-size: .66rem; white-space: nowrap;
95 overflow: hidden; text-overflow: ellipsis; }
96 .slot .hp { display: block; height: 5px; margin-top: .2rem; border-radius: 3px; background: #2a2f3a; }
97 .slot .hp i { display: block; height: 100%; border-radius: 3px; background: #6fcf6f; }
98 .slot.mine { border-color: #6f8ecf; background: #1b2130; }
99 .slot.down { opacity: .42; filter: grayscale(1); }
100 .slot.down .who { text-decoration: line-through; }
101 .slot .spike { color: #f0d060; }
102 #feed { position: absolute; top: .5rem; right: .5rem; width: min(17rem, 52%); display: grid;
103 gap: .18rem; justify-items: end; pointer-events: none; font-size: .76rem; }
104 #feed div { background: #0b0d11cc; border: 1px solid #2f3541; border-radius: .3rem;
105 padding: .1rem .4rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
106 max-width: 100%; }
107 #feed .k { color: #8ef0c4; } #feed .v { color: #e39a95; } #feed .w { opacity: .65; }
73108 #hud { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; margin: .5rem 0;
74109 font-variant-numeric: tabular-nums; }
75110 .chip { background: #1b1e25; border: 1px solid #2a2f3a; border-radius: .4rem; padding: .3rem .55rem;
@@ -118,9 +153,42 @@ export function gamePage(opts: {
118153 <div id="stage">
119154 <canvas id="view" width="900" height="570"></canvas>
120155 <div id="flash"></div>
156 <div id="aimhint">click to aim · esc releases</div>
121157 <div id="dead">waiting for the next round</div>
158 <div id="feed"></div>
159 <div id="prompt">Click the map to aim, then press <b>H</b> for how to play.</div>
160 <div id="help" role="dialog" aria-label="How to play val"><div>
161 <h2>How to play</h2>
162 <p><strong>The round.</strong> Attackers carry the spike to site A or B and plant it;
163 defenders stop them, or defuse it once it is down. Plant and it runs a 45s fuse.
164 First team to 13 rounds wins, and the sides swap at 12.</p>
165 <p><strong>The teams.</strong> You are <span class="red">red</span> or
166 <span class="blue">blue</span>; each round one team attacks and the other defends.
167 Team mates carry a name tag over their head, enemies never do.</p>
168 <p><strong>Controls.</strong> <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> move ·
169 click the view to capture the mouse and look, <kbd>Esc</kbd> releases ·
170 click to fire · <kbd>R</kbd> reload · hold <kbd>F</kbd> to plant, defuse or pick up ·
171 <kbd>Tab</kbd> holds the big map open · <kbd>V</kbd> swaps to the top-down view ·
172 <kbd>B</kbd> opens the buy · <kbd>H</kbd> brings this card back.</p>
173 <p><strong>Abilities.</strong> <kbd>C</kbd> and <kbd>Q</kbd> are bought in the buy phase and
174 last the round, <kbd>E</kbd> is free and comes back on a cooldown, and <kbd>X</kbd> is
175 the ultimate, paid for in ult points you earn by playing.</p>
176 <p><strong>Telling people apart.</strong> Your five are always listed under the view with their
177 health — struck through when they are down — and the last few kills scroll in the top right.
178 In the world an enemy wears a red chevron over their head and a hard dark outline, a team mate
179 wears their name and a health pip, and anybody lying flat on the floor is dead. On the map your
180 team is always shown (crossed out when down) and enemies appear only while you can see them.</p>
181 <p><strong>On a phone</strong> the view stays top-down: the left thumb walks, the right
182 thumb aims and fires, and the buttons under the map buy and cast.</p>
183 <ul id="helpagents"></ul>
184 <button id="helpclose" type="button">Got it — play</button>
185 </div></div>
122186 </div>
123187
188 <div id="roster" aria-label="your team">${["", "", "", "", ""]
189 .map(() => `<div class="slot"><span class="who">—</span><span class="hp"><i style="width:0"></i></span></div>`)
190 .join("")}</div>
191
124192 <div id="hud">
125193 <span class="chip" id="hp">100 hp</span>
126194 <span class="chip" id="gun">Classic 12</span>
@@ -128,6 +196,8 @@ export function gamePage(opts: {
128196 <span class="chip" id="ultmeter">ult 0/6</span>
129197 <span class="kit" id="kit"></span>
130198 <button id="buytoggle" class="chip" type="button">buy (B)</button>
199 <button id="viewtoggle" class="chip" type="button">view (V)</button>
200 <button id="helptoggle" class="chip" type="button">how to play (H)</button>
131201 </div>
132202
133203 <div id="buy">
@@ -136,13 +206,16 @@ export function gamePage(opts: {
136206 (a) => `<button type="button" data-agent="${escape(a.id)}" title="${escape(a.blurb)}">${escape(a.name)} <small>${escape(a.role)}</small></button>`,
137207 ).join("")}</div>
138208 <div class="buys" id="guns">${weaponIds()
139 .map((id) => `<button type="button" data-buy="${id}">${escape(WEAPONS[id].name)} <b>${WEAPONS[id].cost}</b></button>`)
209 .map(
210 (id) =>
211 `<button type="button" data-buy="${id}" data-cost="${WEAPONS[id].cost}" title="${escape(WEAPONS[id].name)} — ${WEAPONS[id].cost} credits, ${WEAPONS[id].magazine} rounds, ${WEAPONS[id].auto ? "automatic" : "semi-automatic"}">${escape(WEAPONS[id].name)} <b>${WEAPONS[id].cost}</b></button>`,
212 )
140213 .join("")}</div>
141214 <div class="buys" id="gear">
142 <button type="button" data-buy="light">Light shield <b>400</b></button>
143 <button type="button" data-buy="heavy">Heavy shield <b>1000</b></button>
144 <button type="button" data-buy="c">Ability C</button>
145 <button type="button" data-buy="q">Ability Q</button>
215 <button type="button" data-buy="light" data-cost="400" title="25 armour that soaks a quarter of what hits you">Light shield <b>400</b></button>
216 <button type="button" data-buy="heavy" data-cost="1000" title="50 armour that soaks half of what hits you">Heavy shield <b>1000</b></button>
217 <button type="button" data-buy="c" data-cost="0">C ability <b>—</b></button>
218 <button type="button" data-buy="q" data-cost="0">Q ability <b>—</b></button>
146219 </div>
147220 </div>
148221
@@ -162,9 +235,11 @@ export function gamePage(opts: {
162235 </div>
163236
164237 <footer>
165 <p><strong>Controls.</strong> WASD move · mouse aims · click fires · R reloads ·
166 <strong>F</strong> plants and defuses (hold it) · C / Q / E / X cast · B opens the buy.
167 On a phone: left thumb walks, right thumb aims and fires, the buttons under the map do the rest.</p>
238 <p><strong>Controls.</strong> WASD move · click the view to capture the mouse, move to look,
239 Esc releases · click fires · R reloads · <strong>F</strong> plants and defuses (hold it) ·
240 C / Q / E / X cast · Tab holds the map open · V swaps to the top-down view ·
241 B opens the buy · H shows how to play.
242 On a phone it stays top-down: left thumb walks, right thumb aims and fires.</p>
168243 <p>Five attackers carry the spike, five defenders stop them. 100s round, 4s plant, 45s fuse,
169244 7s defuse with a checkpoint at half. First to 13, sides swap at 12. Bots fill every seat
170245 nobody is in, and they use their kit.</p>
src/prompt.test.ts added +84
@@ -0,0 +1,84 @@
1 import { describe, expect, test } from "bun:test";
2 import { choosePrompt } from "../static/prompt.js";
3
4 type Snap = ReturnType<typeof snapshot>;
5
6 const snapshot = (over: Record<string, unknown> = {}, me: Record<string, unknown> = {}, spike: Record<string, unknown> = {}) => ({
7 phase: "live",
8 clock: 40,
9 result: null,
10 me: { side: "atk", alive: true, blind: 0, planting: 0, spike: false, ...me },
11 spike: { planted: false, carried: false, fuse: 0, defuse: 0, x: 100, y: 100, ...spike },
12 ...over,
13 });
14
15 describe("the one prompt on screen", () => {
16 test("the buy phase says what to do and how long there is", () => {
17 const text = choosePrompt(snapshot({ phase: "buy", clock: 14.2 }));
18 expect(text).toBe("Buy phase — 15s left. Click a gun you can afford.");
19 });
20
21 test("the terminal is told about keys, not clicks", () => {
22 const text = choosePrompt(snapshot({ phase: "buy", clock: 14.2 }), { input: "keys" });
23 expect(text).toBe("Buy phase — 15s left. Press b and buy a gun you can afford.");
24 });
25
26 test("carrying the spike off a site sends you to one", () => {
27 expect(choosePrompt(snapshot({}, { spike: true }), { onSite: false })).toBe(
28 "You have the spike — get to site A or B",
29 );
30 });
31
32 test("carrying it onto a site says how to plant", () => {
33 expect(choosePrompt(snapshot({}, { spike: true }), { onSite: true })).toBe("Hold F to plant");
34 });
35
36 test("a defender near a planted spike is told to defuse, with the fuse", () => {
37 const text = choosePrompt(snapshot({}, { side: "def" }, { planted: true, fuse: 31.4 }), { nearSpike: true });
38 expect(text).toBe("Spike down, 32s — hold F to defuse");
39 });
40
41 test("a defender across the map is told to get to it", () => {
42 const text = choosePrompt(snapshot({}, { side: "def" }, { planted: true, fuse: 31.4 }), { nearSpike: false });
43 expect(text).toBe("Spike down, 32s — get to it and hold F");
44 });
45
46 test("an attacker with it planted is told to hold the site", () => {
47 expect(choosePrompt(snapshot({}, {}, { planted: true, fuse: 10 }))).toBe(
48 "Spike down, 10s — keep them off it",
49 );
50 });
51
52 test("blind beats everything else on screen", () => {
53 const text = choosePrompt(snapshot({}, { blind: 1.4, spike: true }), { onSite: true });
54 expect(text).toBe("You are blind — back off until it clears");
55 });
56
57 test("dead in a live round says you are still in the match", () => {
58 expect(choosePrompt(snapshot({}, { alive: false }))).toBe("Waiting for the next round — you're in it");
59 });
60
61 test("the end of a round shows the result and the countdown", () => {
62 const text = choosePrompt(snapshot({ phase: "end", clock: 4.1, result: "Spike defused" }));
63 expect(text).toBe("Spike defused — next round in 5s");
64 });
65
66 test("a live defender is told what defending is", () => {
67 expect(choosePrompt(snapshot({}, { side: "def" }))).toBe("Defending — hold A and B, 40s");
68 });
69
70 test("an attacker beside a loose spike is told to walk onto it", () => {
71 expect(choosePrompt(snapshot(), { nearSpike: true })).toBe("Walk onto the spike to pick it up");
72 });
73
74 test("defusing counts down the defuse, not the fuse", () => {
75 const text = choosePrompt(snapshot({}, { side: "def" }, { planted: true, fuse: 20, defuse: 3.5 }), {
76 nearSpike: true,
77 });
78 expect(text).toBe("Defusing — keep holding F, 4s");
79 });
80
81 test("no snapshot yet is no prompt", () => {
82 expect(choosePrompt(null as unknown as Snap)).toBe("");
83 });
84 });
src/raycast.test.ts added +185
@@ -0,0 +1,185 @@
1 import { describe, expect, test } from "bun:test";
2 import {
3 angleColumn,
4 bandLight,
5 castRay,
6 columnAngle,
7 columnOffset,
8 FOV,
9 hitResult,
10 project,
11 projection,
12 projectResult,
13 sliceHeight,
14 smokeAlong,
15 spriteColumnVisible,
16 wallLight,
17 } from "../static/raycast.js";
18
19 const W = 900;
20
21 describe("columns and angles", () => {
22 test("the middle column looks straight ahead", () => {
23 expect(columnOffset(W / 2 - 0.5, W)).toBeCloseTo(0, 6);
24 expect(columnAngle(W / 2 - 0.5, W, 1.2)).toBeCloseTo(1.2, 6);
25 });
26
27 test("the outer columns sit at the edges of the field of view", () => {
28 expect(columnOffset(0, W)).toBeCloseTo(-FOV / 2, 2);
29 expect(columnOffset(W - 1, W)).toBeCloseTo(FOV / 2, 2);
30 });
31
32 test("a column maps to an angle and back again", () => {
33 for (const col of [0, 123, 450, 899]) {
34 expect(angleColumn(columnOffset(col, W), W)).toBeCloseTo(col + 0.5, 4);
35 }
36 });
37
38 test("columns are not spread evenly — that is what keeps walls straight", () => {
39 const middle = columnOffset(451, W) - columnOffset(450, W);
40 const edge = columnOffset(1, W) - columnOffset(0, W);
41 expect(edge).toBeLessThan(middle);
42 });
43 });
44
45 describe("wall slices", () => {
46 test("a wall twice as far is half as tall", () => {
47 const proj = projection(W);
48 expect(sliceHeight(160, proj, 80)).toBeCloseTo(sliceHeight(80, proj, 80) / 2, 6);
49 });
50
51 test("a north–south face is darker than an east–west one at the same range", () => {
52 expect(wallLight(200, 1, 80)).toBeLessThan(wallLight(200, 0, 80));
53 });
54
55 test("further is dimmer, and never fully black", () => {
56 expect(wallLight(900, 0, 80)).toBeLessThan(wallLight(120, 0, 80));
57 expect(wallLight(5000, 1, 80)).toBeGreaterThan(0);
58 });
59
60 test("the floor is dimmest at the horizon", () => {
61 expect(bandLight(2, 200)).toBeLessThan(bandLight(180, 200));
62 });
63 });
64
65 // A four-by-four room: solid border, open inside.
66 const room = (col: number, row: number): boolean => col <= 0 || row <= 0 || col >= 3 || row >= 3;
67
68 describe("castRay", () => {
69 const out = hitResult();
70
71 test("finds the wall straight ahead and calls it an east–west face", () => {
72 castRay(room, 120, 120, 0, 80, 2000, out);
73 expect(out.wall).toBe(true);
74 expect(out.dist).toBeCloseTo(120, 6); // from x=120 to the face at x=240
75 expect(out.side).toBe(0);
76 expect(out.col).toBe(3);
77 });
78
79 test("a ray upward strikes a north–south face", () => {
80 castRay(room, 120, 120, -Math.PI / 2, 80, 2000, out);
81 expect(out.wall).toBe(true);
82 expect(out.dist).toBeCloseTo(40, 6);
83 expect(out.side).toBe(1);
84 expect(out.row).toBe(0);
85 });
86
87 test("a ray that runs out of range reports no wall", () => {
88 castRay(room, 120, 120, 0, 80, 50, out);
89 expect(out.wall).toBe(false);
90 expect(out.dist).toBe(50);
91 });
92
93 test("standing inside a wall is zero distance, not an infinite loop", () => {
94 castRay(room, 10, 10, 0.3, 80, 2000, out);
95 expect(out.dist).toBe(0);
96 expect(out.wall).toBe(true);
97 });
98
99 test("a diagonal ray agrees with walking the same line by hand", () => {
100 castRay(room, 120, 120, Math.PI / 4, 80, 2000, out);
101 let step = 0;
102 while (step < 2000 && !room(Math.floor((120 + Math.cos(Math.PI / 4) * step) / 80), Math.floor((120 + Math.sin(Math.PI / 4) * step) / 80)))
103 step += 0.25;
104 expect(out.dist).toBeCloseTo(step, 0);
105 });
106
107 test("it fills the caller's object and allocates nothing", () => {
108 const same = castRay(room, 120, 120, 0, 80, 2000, out);
109 expect(same).toBe(out);
110 });
111 });
112
113 describe("projecting a body onto the screen", () => {
114 const proj = projection(W);
115 const out = projectResult();
116
117 test("something dead ahead lands in the middle of the screen", () => {
118 project(100, 100, 0, 500, 100, W, proj, 80, out);
119 expect(out.behind).toBe(false);
120 expect(out.column).toBeCloseTo(W / 2, 4);
121 expect(out.perp).toBeCloseTo(400, 6);
122 });
123
124 test("something to the right lands right of centre", () => {
125 project(100, 100, 0, 500, 260, W, proj, 80, out);
126 expect(out.column).toBeGreaterThan(W / 2);
127 });
128
129 test("something behind the camera is not drawn at all", () => {
130 project(100, 100, 0, -400, 100, W, proj, 80, out);
131 expect(out.behind).toBe(true);
132 expect(out.size).toBe(0);
133 });
134
135 test("the further body is the smaller one", () => {
136 project(100, 100, 0, 300, 100, W, proj, 80, out);
137 const near = out.size;
138 project(100, 100, 0, 900, 100, W, proj, 80, out);
139 expect(out.size).toBeLessThan(near);
140 });
141
142 test("perpendicular depth, not straight-line distance, so the edges do not bulge", () => {
143 project(100, 100, 0, 500, 300, W, proj, 80, out);
144 expect(out.perp).toBeLessThan(out.dist);
145 });
146 });
147
148 describe("z-clip", () => {
149 test("a body nearer than the wall behind it is drawn", () => {
150 expect(spriteColumnVisible(300, 900)).toBe(true);
151 });
152
153 test("a body behind the corner is hidden", () => {
154 expect(spriteColumnVisible(900, 300)).toBe(false);
155 });
156
157 test("a body flush against the wall does not bleed through it", () => {
158 expect(spriteColumnVisible(299, 300)).toBe(false);
159 });
160 });
161
162 describe("smoke as a volume", () => {
163 test("a ray through the middle of a smoke is fully fogged", () => {
164 expect(smokeAlong(0, 0, 1, 0, 400, 0, 110, 1000)).toBe(1);
165 });
166
167 test("a ray that misses it is not fogged at all", () => {
168 expect(smokeAlong(0, 0, 1, 0, 400, 400, 110, 1000)).toBe(0);
169 });
170
171 test("a wall in front of the smoke is not fogged by it", () => {
172 expect(smokeAlong(0, 0, 1, 0, 400, 0, 110, 200)).toBe(0);
173 });
174
175 test("clipping the edge fogs less than going through the middle", () => {
176 const edge = smokeAlong(0, 0, 1, 0, 400, 100, 110, 1000);
177 expect(edge).toBeGreaterThan(0);
178 expect(edge).toBeLessThan(1);
179 });
180
181 test("standing in the middle of one is nearly opaque in every direction", () => {
182 expect(smokeAlong(400, 0, 1, 0, 400, 0, 110, 1000)).toBeGreaterThan(0.9);
183 expect(smokeAlong(400, 0, 0, -1, 400, 0, 110, 1000)).toBeGreaterThan(0.9);
184 });
185 });
src/roster.test.ts added +64
@@ -0,0 +1,64 @@
1 import { describe, expect, test } from "bun:test";
2 import { roster, rosterKey } from "../static/roster.js";
3
4 const snapshot = (players: unknown[]) => ({
5 me: { id: "p3" },
6 players,
7 });
8
9 const mate = (id: string, over: Record<string, unknown> = {}) => ({
10 id,
11 name: id.toUpperCase(),
12 agent: "veil",
13 hp: 100,
14 alive: true,
15 mate: true,
16 spike: false,
17 ...over,
18 });
19
20 describe("the team roster", () => {
21 test("is your own side, everybody, seen or not", () => {
22 const rows = roster(
23 snapshot([mate("p1"), mate("p2"), mate("p3"), mate("p4"), mate("p5"), mate("e1", { mate: false })]),
24 );
25 expect(rows.length).toBe(5);
26 expect(rows.some((r) => r.id === "e1")).toBe(false);
27 });
28
29 test("keeps one order whatever order the snapshot arrives in", () => {
30 const one = roster(snapshot([mate("p3"), mate("p1"), mate("p2")]));
31 const two = roster(snapshot([mate("p2"), mate("p3"), mate("p1")]));
32 expect(one.map((r) => r.id)).toEqual(two.map((r) => r.id));
33 expect(one.map((r) => r.id)).toEqual(["p1", "p2", "p3"]);
34 });
35
36 test("marks the dead and gives them no health", () => {
37 const rows = roster(snapshot([mate("p1", { alive: false, hp: 37 })]));
38 expect(rows[0]!.alive).toBe(false);
39 expect(rows[0]!.hp).toBe(0);
40 });
41
42 test("clamps a health the server rounded past the ends", () => {
43 expect(roster(snapshot([mate("p1", { hp: 140 })]))[0]!.hp).toBe(100);
44 expect(roster(snapshot([mate("p1", { hp: -5 })]))[0]!.hp).toBe(0);
45 });
46
47 test("knows which row is you, and who has the spike", () => {
48 const rows = roster(snapshot([mate("p1"), mate("p3", { spike: true })]));
49 expect(rows.find((r) => r.id === "p3")!.mine).toBe(true);
50 expect(rows.find((r) => r.id === "p3")!.spike).toBe(true);
51 expect(rows.find((r) => r.id === "p1")!.mine).toBe(false);
52 });
53
54 test("no snapshot is no rows, not a crash", () => {
55 expect(roster(null)).toEqual([]);
56 });
57
58 test("the key changes when anything on the panel changes, and not otherwise", () => {
59 const before = roster(snapshot([mate("p1", { hp: 80 })]));
60 expect(rosterKey(before)).toBe(rosterKey(roster(snapshot([mate("p1", { hp: 80 })]))));
61 expect(rosterKey(before)).not.toBe(rosterKey(roster(snapshot([mate("p1", { hp: 40 })]))));
62 expect(rosterKey(before)).not.toBe(rosterKey(roster(snapshot([mate("p1", { hp: 80, alive: false })]))));
63 });
64 });
src/scene.test.ts added +231
@@ -0,0 +1,231 @@
1 /**
2 * The renderer, run for real against a canvas that writes down what it was
3 * asked to draw. No browser, but every line of the draw path is executed —
4 * which is what catches a typo in a frame nobody can see from a unit test.
5 */
6
7 import { expect, test } from "bun:test";
8 import { drawGun, makeGun } from "../static/gun.js";
9 import { drawScene, makeScene } from "../static/scene.js";
10 import { drawTopdown } from "../static/topdown.js";
11 import { gridRows, TILE } from "./map.ts";
12
13 type Op = { op: string; args: unknown[] };
14
15 /** A canvas that records. Every call the renderer makes has to exist here. */
16 function fakeContext() {
17 const ops: Op[] = [];
18 const note =
19 (op: string) =>
20 (...args: unknown[]) => {
21 ops.push({ op, args });
22 };
23 const ctx = {
24 ops,
25 fillStyle: "",
26 strokeStyle: "",
27 lineWidth: 1,
28 font: "",
29 textAlign: "left",
30 globalAlpha: 1,
31 clearRect: note("clearRect"),
32 fillRect: note("fillRect"),
33 strokeRect: note("strokeRect"),
34 beginPath: note("beginPath"),
35 closePath: note("closePath"),
36 moveTo: note("moveTo"),
37 lineTo: note("lineTo"),
38 arc: note("arc"),
39 ellipse: note("ellipse"),
40 fill: note("fill"),
41 stroke: note("stroke"),
42 clip: note("clip"),
43 rect: note("rect"),
44 save: note("save"),
45 restore: note("restore"),
46 translate: note("translate"),
47 rotate: note("rotate"),
48 fillText: note("fillText"),
49 measureText: (text: string) => ({ width: text.length * 6 }),
50 createLinearGradient: () => ({ addColorStop() {} }),
51 };
52 return ctx;
53 }
54
55 // A room three tiles deep running east, with a pillar standing in it at
56 // column 5 — enough geometry for a body to hide behind.
57 const room = (col: number, row: number): boolean =>
58 col < 1 || col > 12 || row < 1 || row > 3 || col === 5;
59 const world = { solid: room, tile: TILE };
60
61 const snapshot = (players: unknown[], zones: unknown[] = []) => ({
62 phase: "live",
63 clock: 40,
64 result: null,
65 me: { id: "me", x: 120, y: 200, angle: 0, alive: true, side: "atk", hp: 100, blind: 0, ammo: 25, reloading: false, planting: 0, spike: false, agent: "ember" },
66 players,
67 zones,
68 orbs: [],
69 shots: [],
70 spike: { planted: false, carried: false, fuse: 0, defuse: 0, x: null, y: null },
71 });
72
73 const body = (over: Record<string, unknown> = {}) => ({
74 id: "them",
75 name: "Sable",
76 agent: "sable",
77 x: 280,
78 y: 200,
79 angle: 3.1,
80 hp: 100,
81 alive: true,
82 mate: false,
83 spike: false,
84 blind: false,
85 ...over,
86 });
87
88 const view = { width: 900, height: 570, look: (p: { x: number; y: number; angle: number }) => p };
89 const eye = { x: 120, y: 200, angle: 0 };
90
91 test("a frame draws walls: one filled column per screen column, and the horizon", () => {
92 const ctx = fakeContext();
93 drawScene(ctx as never, makeScene(), world, eye, snapshot([]), view);
94 const rects = ctx.ops.filter((o) => o.op === "fillRect");
95 // The two bands and the horizon, then a slice for every column of the wall.
96 expect(rects.length).toBeGreaterThan(800);
97 });
98
99 test("a body in the open is drawn; the same body behind the pillar is not", () => {
100 const open = fakeContext();
101 drawScene(open as never, makeScene(), world, eye, snapshot([body()]), view);
102 // The head is the only arc in this scene, so it stands for the body.
103 expect(open.ops.some((o) => o.op === "arc")).toBe(true);
104
105 const hidden = fakeContext();
106 drawScene(hidden as never, makeScene(), world, eye, snapshot([body({ x: 700 })]), view);
107 expect(hidden.ops.some((o) => o.op === "arc")).toBe(false);
108 });
109
110 test("a team mate gets a name tag and an enemy never does", () => {
111 const mate = fakeContext();
112 drawScene(mate as never, makeScene(), world, eye, snapshot([body({ mate: true })]), view);
113 expect(mate.ops.some((o) => o.op === "fillText" && o.args[0] === "Sable")).toBe(true);
114
115 const enemy = fakeContext();
116 drawScene(enemy as never, makeScene(), world, eye, snapshot([body()]), view);
117 expect(enemy.ops.some((o) => o.op === "fillText")).toBe(false);
118 });
119
120 test("the dead lie on the floor and wear no markers at all", () => {
121 const ctx = fakeContext();
122 drawScene(ctx as never, makeScene(), world, eye, snapshot([body({ mate: true, alive: false, hp: 0 })]), view);
123 // Flat on the ground: an ellipse, not an upright body with a head.
124 expect(ctx.ops.some((o) => o.op === "ellipse")).toBe(true);
125 // No name tag, and no chevron — the chevron is the only closed path here.
126 expect(ctx.ops.some((o) => o.op === "fillText")).toBe(false);
127 expect(ctx.ops.some((o) => o.op === "closePath")).toBe(false);
128 });
129
130 test("an enemy wears a chevron and a team mate does not", () => {
131 const enemy = fakeContext();
132 drawScene(enemy as never, makeScene(), world, eye, snapshot([body()]), view);
133 expect(enemy.ops.some((o) => o.op === "closePath")).toBe(true);
134
135 const mate = fakeContext();
136 drawScene(mate as never, makeScene(), world, eye, snapshot([body({ mate: true })]), view);
137 expect(mate.ops.some((o) => o.op === "closePath")).toBe(false);
138 expect(mate.ops.some((o) => o.op === "fillText")).toBe(true);
139 });
140
141 test("a body has a hard dark edge, whichever side it is on", () => {
142 const ctx = fakeContext();
143 drawScene(ctx as never, makeScene(), world, eye, snapshot([body()]), view);
144 expect(ctx.ops.some((o) => o.op === "strokeRect")).toBe(true);
145 });
146
147 test("a smoke between the eye and a body hides it", () => {
148 const smoke = [{ k: "smoke", x: 200, y: 200, r: 90, f: 0, l: 8 }];
149 const ctx = fakeContext();
150 drawScene(ctx as never, makeScene(), world, eye, snapshot([body()], smoke), view);
151 expect(ctx.ops.some((o) => o.op === "arc")).toBe(false);
152
153 // A smoke that is not on the line does not hide anything.
154 const aside = [{ k: "smoke", x: 200, y: 900, r: 90, f: 0, l: 8 }];
155 const clear = fakeContext();
156 drawScene(clear as never, makeScene(), world, eye, snapshot([body()], aside), view);
157 expect(clear.ops.some((o) => o.op === "arc")).toBe(true);
158 });
159
160 test("every kind of zone, the spike and a tracer draw without falling over", () => {
161 const zones = ["smoke", "molly", "wall", "trip", "dart", "flash", "heal"].map((k, i) => ({
162 k,
163 x: 300 + i * 20,
164 y: 200,
165 r: 90,
166 f: k === "flash" ? 0.3 : 0,
167 l: 5,
168 }));
169 const snap = snapshot([body(), body({ id: "mate", mate: true, alive: false, hp: 0 })], zones);
170 snap.orbs = [{ x: 360, y: 200 }] as never;
171 snap.shots = [{ x1: 130, y1: 200, x2: 380, y2: 200, hit: true, age: 0 }] as never;
172 snap.spike = { planted: true, carried: false, fuse: 12, defuse: 0, x: 330, y: 200 } as never;
173 const ctx = fakeContext();
174 drawScene(ctx as never, makeScene(), world, eye, snap, view);
175 expect(ctx.ops.length).toBeGreaterThan(100);
176 expect(ctx.globalAlpha).toBe(1);
177 });
178
179 test("the top-down view draws the real map, small or large", () => {
180 const map = { rows: gridRows(), tile: TILE };
181 const snap = snapshot([body({ mate: true })]);
182 const big = fakeContext();
183 drawTopdown(big as never, map, snap, (p: never) => p, { x: 0, y: 0, w: 900, h: 570 });
184 const small = fakeContext();
185 drawTopdown(small as never, map, snap, (p: never) => p, { x: 8, y: 8, w: 260, h: 165 }, { labels: false });
186 expect(big.ops.some((o) => o.op === "fillText")).toBe(true);
187 expect(small.ops.some((o) => o.op === "fillText")).toBe(false);
188 expect(small.ops.some((o) => o.op === "translate")).toBe(true);
189 });
190
191 test("on the map a dead team mate is crossed out and a dead enemy is not there", () => {
192 const map = { rows: gridRows(), tile: TILE };
193 const box = { x: 0, y: 0, w: 900, h: 570 };
194 const look = (p: never) => p;
195
196 const mate = fakeContext();
197 drawTopdown(mate as never, map, snapshot([body({ mate: true, alive: false })]), look, box);
198 // Two strokes through the dot, and no dot: a cross is not a live player.
199 expect(mate.ops.filter((o) => o.op === "moveTo").length).toBe(2);
200 expect(mate.ops.some((o) => o.op === "arc")).toBe(false);
201
202 const enemy = fakeContext();
203 drawTopdown(enemy as never, map, snapshot([body({ alive: false })]), look, box);
204 const empty = fakeContext();
205 drawTopdown(empty as never, map, snapshot([]), look, box);
206 expect(enemy.ops.length).toBe(empty.ops.length);
207 });
208
209 test("a living team mate carries their initial on the big map", () => {
210 const map = { rows: gridRows(), tile: TILE };
211 const big = fakeContext();
212 drawTopdown(big as never, map, snapshot([body({ mate: true, name: "Wren" })]), (p: never) => p, {
213 x: 0,
214 y: 0,
215 w: 900,
216 h: 570,
217 });
218 expect(big.ops.some((o) => o.op === "fillText" && o.args[0] === "W")).toBe(true);
219 });
220
221 test("the gun and the crosshair draw, and the hit marker only when there is one", () => {
222 const gun = makeGun();
223 const ctx = fakeContext();
224 drawGun(ctx as never, gun, 900, 570, snapshot([]));
225 const quiet = ctx.ops.length;
226 gun.mark = 1;
227 gun.kick = 1;
228 const loud = fakeContext();
229 drawGun(loud as never, gun, 900, 570, snapshot([]));
230 expect(loud.ops.length).toBeGreaterThan(quiet);
231 });
src/server.test.ts +44 −1
@@ -6,7 +6,7 @@ import { join } from "node:path";
66 process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-"));
77 process.env.PORT = "0";
88 const { server } = await import("./server.ts");
9 const { match, snapshot, advance } = await import("./arena.ts");
9 const { match, snapshot, advance, takeSeat } = await import("./arena.ts");
1010 const base = `http://${server.hostname}:${server.port}`;
1111
1212 test("serves a live match on the first paint, with no script needed", async () => {
@@ -123,6 +123,11 @@ test("a snapshot only carries the enemies that viewer can see", () => {
123123 viewer.x = 1200;
124124 viewer.y = 1320;
125125 viewer.blind = 0;
126 // Both of them alive on purpose: the arena is one live match that other
127 // tests run forward, and a dead enemy is invisible for a different reason
128 // than the one this test is about.
129 viewer.alive = true;
130 enemy.alive = true;
126131 enemy.x = 1200;
127132 enemy.y = 1240;
128133 enemy.revealed = 0;
@@ -176,3 +181,41 @@ test("the match keeps running and is never parked", () => {
176181 expect(state.now).toBeGreaterThan(before);
177182 expect(["buy", "live", "end", "over"]).toContain(state.phase);
178183 });
184
185 test("the kit is public: every ability, its price and what it does", async () => {
186 const response = await fetch(base + "/agents.json");
187 expect(response.status).toBe(200);
188 expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
189 const body = (await response.json()) as { agents: { id: string; abilities: Record<string, { name: string; cost: number; text: string }> }[] };
190 expect(body.agents.length).toBe(5);
191 const veil = body.agents.find((a) => a.id === "veil")!;
192 expect(veil.abilities.c.name).toBe("Cloudburst");
193 expect(veil.abilities.c.cost).toBe(100);
194 expect(veil.abilities.e.text).toContain("smoke");
195 // The ultimate is never for sale, and no server-only state leaks out.
196 expect(veil.abilities.x.cost).toBe(0);
197 expect(JSON.stringify(body)).not.toContain("kind");
198 });
199
200 test("the page carries the first-run card and the prompt line", async () => {
201 const body = await (await fetch(base + "/")).text();
202 expect(body).toContain('id="help"');
203 expect(body).toContain("How to play");
204 expect(body).toContain('id="prompt"');
205 // Every buy button says what it costs, so the panel is readable at a glance.
206 expect(body).toContain('data-buy="vandal" data-cost="2900"');
207 expect(body).toContain('id="viewtoggle"');
208 });
209
210 test("two people who pick the same name do not end up with the same name", () => {
211 // Everything that addresses a player — the kill feed, the round log, the
212 // scoreboard — does it by name, so the match may never hold two of one.
213 const first = takeSeat("clash-a", "Twin")!;
214 const second = takeSeat("clash-b", "Twin")!;
215 expect(first.name).toBe("Twin");
216 expect(second.name).not.toBe("Twin");
217 expect(second.name.startsWith("Twin")).toBe(true);
218 expect(second.name.length).toBeLessThanOrEqual(16);
219 const names = match().players.map((p) => p.name);
220 expect(new Set(names).size).toBe(names.length);
221 });
src/server.ts +44 −4
@@ -9,6 +9,7 @@
99
1010 import { createHash, randomUUID } from "node:crypto";
1111 import { join } from "node:path";
12 import { AGENTS, SLOTS } from "./abilities.ts";
1213 import { attach, command, findPlayer, match, release, snapshot, startArena, takeSeat, type Command } from "./arena.ts";
1314 import { recentResults } from "./db.ts";
1415 import { gridRows, TILE } from "./map.ts";
@@ -103,10 +104,17 @@ async function readForm(request: Request): Promise<FormData | undefined> {
103104
104105 // ------------------------------------------------------------- the client
105106
106 // The client script is named by its content: a change is a new URL, so a
107 // year-long cache is never stale.
108 const scriptPath = join(import.meta.dir, "..", "static", "val.js");
109 const scriptBody = await Bun.file(scriptPath).text();
107 // The client is a handful of small modules under static/ — the renderer, the
108 // map, the crosshair, the ray maths — bundled into one file at start-up so a
109 // browser makes one request and the maths can still be imported by the tests.
110 // It is named by its content: a change is a new URL, so a year-long cache is
111 // never stale.
112 const built = await Bun.build({
113 entrypoints: [join(import.meta.dir, "..", "static", "val.js")],
114 target: "browser",
115 });
116 if (!built.success) throw new AggregateError(built.logs, "the client would not build");
117 const scriptBody = await built.outputs[0]!.text();
110118 const scriptHash = createHash("sha256").update(scriptBody).digest("hex").slice(0, 10);
111119 const scriptUrl = `/static/val.${scriptHash}.js`;
112120
@@ -192,6 +200,38 @@ export const server = Bun.serve<SocketData, Record<string, never>>({
192200 : response;
193201 }
194202
203 // The agents and their kits: what every ability does, its price and its
204 // charges, so the buy panel can label itself. The same table the server
205 // plays by, and no secret in it.
206 if (url.pathname === "/agents.json" && (request.method === "GET" || request.method === "HEAD"))
207 return Response.json(
208 {
209 agents: AGENTS.map((a) => ({
210 id: a.id,
211 name: a.name,
212 role: a.role,
213 blurb: a.blurb,
214 abilities: Object.fromEntries(
215 SLOTS.map((slot) => {
216 const ability = a.abilities[slot];
217 return [
218 slot,
219 {
220 name: ability.name,
221 cost: ability.cost,
222 charges: ability.charges,
223 cooldown: ability.cooldown,
224 points: ability.points,
225 text: ability.text,
226 },
227 ];
228 }),
229 ),
230 })),
231 },
232 { headers: { "cache-control": "public, max-age=300", ...SECURITY } },
233 );
234
195235 // The map is the same for everybody and changes only when the code does.
196236 if (url.pathname === "/map.json" && (request.method === "GET" || request.method === "HEAD"))
197237 return Response.json(
src/steer.test.ts added +35
@@ -0,0 +1,35 @@
1 import { expect, test } from "bun:test";
2 import { moveVector } from "../static/steer.js";
3
4 test("W walks the way you are looking, whichever way that is", () => {
5 const east = moveVector(0, -1, 0);
6 expect(east.x).toBeCloseTo(1, 6);
7 expect(east.y).toBeCloseTo(0, 6);
8 const south = moveVector(0, -1, Math.PI / 2);
9 expect(south.x).toBeCloseTo(0, 6);
10 expect(south.y).toBeCloseTo(1, 6);
11 });
12
13 test("D strafes across the facing, not along it", () => {
14 const right = moveVector(1, 0, 0);
15 expect(right.x).toBeCloseTo(0, 6);
16 expect(right.y).toBeCloseTo(1, 6);
17 const other = moveVector(-1, 0, 0);
18 expect(other.y).toBeCloseTo(-1, 6);
19 });
20
21 test("forward and strafe together keep their length", () => {
22 const both = moveVector(1, -1, 0.7);
23 expect(Math.hypot(both.x, both.y)).toBeCloseTo(Math.SQRT2, 6);
24 });
25
26 test("standing still stays still", () => {
27 const still = moveVector(0, 0, 2.2);
28 expect(still.x).toBeCloseTo(0, 6);
29 expect(still.y).toBeCloseTo(0, 6);
30 });
31
32 test("it fills the object it was given, so the input loop allocates nothing", () => {
33 const out = { x: 0, y: 0 };
34 expect(moveVector(0, -1, 0, out)).toBe(out);
35 });
src/tty.ts +29 −1
@@ -7,6 +7,8 @@
77 * The map is the same grid, one character a tile. Plain ANSI, no dependencies.
88 */
99
10 import { choosePrompt, REACH } from "../static/prompt.js";
11
1012 const base =
1113 process.env.VAL_URL ??
1214 (process.env.PORT ? `http://127.0.0.1:${process.env.PORT}` : undefined) ??
@@ -38,6 +40,7 @@ let status = "connecting";
3840 let aim = -Math.PI / 2;
3941 let move = { mx: 0, my: 0, until: 0 };
4042 let firing = 0;
43 let helping = false;
4144 let using = 0;
4245 let buying = false;
4346
@@ -71,6 +74,28 @@ async function connect(): Promise<void> {
7174
7275 const colour = (s: string, code: number): string => `${esc}${code}m${s}${esc}0m`;
7376
77 /** The same line the page shows, from the same function. */
78 function prompt(s: Snapshot): string {
79 const row = map ? map.rows[Math.floor(s.me.y / map.tile)] : "";
80 const cell = row ? row[Math.floor(s.me.x / map.tile)] : "";
81 return choosePrompt(s, {
82 input: "keys",
83 onSite: cell === "A" || cell === "B",
84 nearSpike:
85 s.spike.x !== null && s.spike.y !== null && Math.hypot(s.spike.x - s.me.x, s.spike.y - s.me.y) < REACH,
86 });
87 }
88
89 const HOW_TO = [
90 "How to play — attackers carry the spike to site A or B and plant it (hold f);",
91 "defenders stop them, or hold f on a planted spike to defuse it. 45s fuse, first to 13.",
92 "· wasd walks, ←/→ turns the aim, space fires, r reloads",
93 "· c q e x cast the kit: c and q are bought in the buy phase, e is free on a cooldown, x is the ultimate",
94 "· b opens the buy — the same prices the page charges, refused server-side if you cannot afford them",
95 "· A and B on the map are the sites, @ is you, cyan is a team mate, red is an enemy you can see",
96 "h closes this.",
97 ];
98
7499 function draw(): void {
75100 if (!map) {
76101 out(`${esc}2J${esc}Hval — ${status}\r\n`);
@@ -101,6 +126,7 @@ function draw(): void {
101126 lines.push(
102127 `${me.alive ? colour(`${me.hp}hp${me.armour ? "+" + me.armour : ""}`, 32) : colour("dead", 90)} ${me.weapon} ${me.ammo} ${me.credits}cr ult ${me.ult}/${me.ultNeeds} C${me.charges.c} Q${me.charges.q} E${me.charges.e}${me.spike ? colour(" SPIKE", 33) : ""}${me.blind > 0 ? colour(" BLIND", 97) : ""} aim ${((aim * 180) / Math.PI).toFixed(0)}°`,
103128 );
129 lines.push(colour("› " + prompt(snap), 93));
104130 } else lines.push(`val — ${status}`);
105131 lines.push("");
106132 for (const row of rows) lines.push(" " + row.join(""));
@@ -117,10 +143,11 @@ function draw(): void {
117143 for (const event of snap.events.slice(0, 3)) lines.push(colour("· " + event, 90));
118144 }
119145 lines.push("");
146 if (helping) for (const line of HOW_TO) lines.push(colour(line, 96));
120147 lines.push(
121148 buying
122149 ? colour("buy: 1 Classic 2 Ghost 3 Sheriff 4 Spectre 5 Phantom 6 Vandal 7 Operator · 8 light 9 heavy · c/q ability · b closes", 33)
123 : colour("wasd move · ←/→ aim · space fire · f plant/defuse · c q e x cast · b buy · r reload · q quit", 90),
150 : colour("wasd move · ←/→ aim · space fire · f plant/defuse · c q e x cast · b buy · r reload · h how to play · q quit", 90),
124151 );
125152 out(`${esc}H${esc}J` + lines.join("\r\n"));
126153 }
@@ -146,6 +173,7 @@ process.stdin.on("data", (key: string) => {
146173 return;
147174 }
148175 if (key === "b") return void (buying = true);
176 if (key === "h" || key === "?") return void (helping = !helping);
149177 if (key === "w") move = { mx: move.mx, my: -1, until: now + 400 };
150178 if (key === "s") move = { mx: move.mx, my: 1, until: now + 400 };
151179 if (key === "a") move = { mx: -1, my: move.my, until: now + 400 };
static/beep.js added +62
@@ -0,0 +1,62 @@
1 /**
2 * The spike, out loud.
3 *
4 * One oscillator per beep, scheduled by how much fuse is left: a slow tick
5 * when it is planted, a stutter when it is about to go. No audio files, and
6 * nothing is created until the person has clicked the page — a browser will
7 * not start an audio context before that, and should not.
8 */
9
10 /** Seconds between beeps for this much fuse left. */
11 export function beepInterval(fuse) {
12 if (fuse <= 0) return 0;
13 return Math.max(0.12, Math.min(1, fuse / 30));
14 }
15
16 /** The pitch rises as the fuse burns, so it is audible without watching. */
17 export const beepPitch = (fuse) => 760 + 620 * (1 - Math.min(1, fuse / 45));
18
19 export function makeBeeper() {
20 let audio = null;
21 let next = 0;
22
23 const context = () => {
24 if (audio) return audio;
25 const Ctor = globalThis.AudioContext || globalThis.webkitAudioContext;
26 if (!Ctor) return null;
27 audio = new Ctor();
28 return audio;
29 };
30
31 return {
32 /** Called on a click: browsers only allow audio to start from a gesture. */
33 wake() {
34 const ctx = context();
35 if (ctx && ctx.state === "suspended") ctx.resume();
36 },
37 /** Called every frame with the fuse the server last sent. */
38 update(planted, fuse, now) {
39 if (!planted || fuse <= 0) {
40 next = 0;
41 return;
42 }
43 if (!audio) return; // no gesture yet; nothing to play into
44 if (now < next) return;
45 next = now + beepInterval(fuse);
46 beep(audio, beepPitch(fuse));
47 },
48 };
49 }
50
51 function beep(audio, hz) {
52 const osc = audio.createOscillator();
53 const gain = audio.createGain();
54 osc.type = "square";
55 osc.frequency.value = hz;
56 gain.gain.setValueAtTime(0.0001, audio.currentTime);
57 gain.gain.exponentialRampToValueAtTime(0.09, audio.currentTime + 0.01);
58 gain.gain.exponentialRampToValueAtTime(0.0001, audio.currentTime + 0.09);
59 osc.connect(gain).connect(audio.destination);
60 osc.start();
61 osc.stop(audio.currentTime + 0.1);
62 }
static/gun.js added +101
@@ -0,0 +1,101 @@
1 /**
2 * The gun in your hands: a crosshair, a shape at the bottom of the screen
3 * that kicks when you fire, and a marker when a shot of yours lands.
4 *
5 * The client never decides whether it hit anything — it reads that off the
6 * shots the server sent, the same ones the top-down view draws.
7 */
8
9 export const makeGun = () => ({ kick: 0, mark: 0, ammo: null });
10
11 /** Recoil and the hit marker both fade the same way: fast, and to zero. */
12 export const fade = (value, dt, life) => Math.max(0, value - dt / life);
13
14 /** A shot happened when the magazine went down, never when it was reloaded. */
15 export const firedNow = (before, ammo, reloading) =>
16 before !== null && !reloading && ammo < before;
17
18 /** One of my shots landed: it started where I am standing and the server says it hit. */
19 export function hitMarked(shots, me) {
20 for (const shot of shots) {
21 if (!shot.hit) continue;
22 if (Math.hypot(shot.x1 - me.x, shot.y1 - me.y) < 30) return true;
23 }
24 return false;
25 }
26
27 /** Fold a fresh snapshot into the gun's state: kick on a shot, mark on a hit. */
28 export function gunSnapshot(gun, snap) {
29 if (firedNow(gun.ammo, snap.me.ammo, snap.me.reloading)) gun.kick = 1;
30 if (snap.me.alive && hitMarked(snap.shots, snap.me)) gun.mark = 1;
31 gun.ammo = snap.me.ammo;
32 }
33
34 export function gunTick(gun, dt) {
35 gun.kick = fade(gun.kick, dt, 0.12);
36 gun.mark = fade(gun.mark, dt, 0.35);
37 }
38
39 /** The crosshair, the weapon and the marker, in that order of importance. */
40 export function drawGun(ctx, gun, width, height, snap) {
41 const cx = width / 2;
42 const cy = height / 2;
43 const gap = 6 + gun.kick * 10;
44 const arm = 9;
45 ctx.lineWidth = 2;
46 ctx.strokeStyle = "rgba(20,24,30,0.8)";
47 ctx.beginPath();
48 for (const [dx, dy] of [
49 [1, 0],
50 [-1, 0],
51 [0, 1],
52 [0, -1],
53 ]) {
54 ctx.moveTo(cx + dx * gap, cy + dy * gap);
55 ctx.lineTo(cx + dx * (gap + arm), cy + dy * (gap + arm));
56 }
57 ctx.stroke();
58 ctx.strokeStyle = snap.me.alive ? "#8ef0c4" : "#888";
59 ctx.lineWidth = 1.4;
60 ctx.stroke();
61
62 if (gun.mark > 0) {
63 ctx.strokeStyle = `rgba(255,120,110,${gun.mark})`;
64 ctx.lineWidth = 2.5;
65 ctx.beginPath();
66 for (const [dx, dy] of [
67 [1, 1],
68 [-1, -1],
69 [1, -1],
70 [-1, 1],
71 ]) {
72 ctx.moveTo(cx + dx * 7, cy + dy * 7);
73 ctx.lineTo(cx + dx * 15, cy + dy * 15);
74 }
75 ctx.stroke();
76 }
77
78 if (!snap.me.alive) return;
79 // The weapon: a barrel and a body, off to the right, rising with the kick.
80 const scale = height / 570;
81 const lift = gun.kick * 22 * scale + (snap.me.reloading ? 40 * scale : 0);
82 const bx = width * 0.66;
83 const by = height + lift;
84 ctx.fillStyle = "#23262e";
85 ctx.beginPath();
86 ctx.moveTo(bx - 34 * scale, by);
87 ctx.lineTo(bx - 20 * scale, by - 130 * scale);
88 ctx.lineTo(bx + 16 * scale, by - 150 * scale);
89 ctx.lineTo(bx + 30 * scale, by - 40 * scale);
90 ctx.lineTo(bx + 60 * scale, by);
91 ctx.closePath();
92 ctx.fill();
93 ctx.fillStyle = "#3b414e";
94 ctx.fillRect(bx - 8 * scale, by - 160 * scale, 22 * scale, 26 * scale);
95 if (gun.kick > 0.35) {
96 ctx.fillStyle = `rgba(255,220,140,${gun.kick})`;
97 ctx.beginPath();
98 ctx.arc(bx + 3 * scale, by - 162 * scale, 18 * scale * gun.kick, 0, 7);
99 ctx.fill();
100 }
101 }
static/prompt.js added +52
@@ -0,0 +1,52 @@
1 /**
2 * One line of advice, in the player's own words.
3 *
4 * A pure function of the snapshot the server sent plus two things the client
5 * works out from the map it was given: whether you are standing on a site,
6 * and whether you are within reach of the spike. Never more than one prompt —
7 * the most urgent thing wins, because two instructions at once is none.
8 */
9
10 /** Within this many units the server lets you pick up, plant or defuse. */
11 export const REACH = 46;
12
13 const secs = (n) => Math.max(0, Math.ceil(n));
14
15 export function choosePrompt(snap, world = {}) {
16 if (!snap) return "";
17 const me = snap.me;
18 const spike = snap.spike;
19 const onSite = world.onSite === true;
20 const nearSpike = world.nearSpike === true;
21 // The page is played with a mouse and the terminal with keys; one line
22 // names the input, so it says the thing the reader can actually do.
23 const buyWith = world.input === "keys" ? "Press b and buy a gun you can afford." : "Click a gun you can afford.";
24
25 if (snap.phase === "over") return snap.result ? `Match over — ${snap.result}` : "Match over";
26 if (me.blind > 0.05) return "You are blind — back off until it clears";
27 if (!me.alive)
28 return snap.phase === "buy"
29 ? "Buy phase — you are back next round"
30 : "Waiting for the next round — you're in it";
31 if (snap.phase === "end") return snap.result ? `${snap.result} — next round in ${secs(snap.clock)}s` : "Round over";
32 if (snap.phase === "buy") return `Buy phase — ${secs(snap.clock)}s left. ${buyWith}`;
33
34 if (me.planting > 0) return `Planting — hold F for ${secs(4 - me.planting)}s more`;
35 if (spike.planted) {
36 if (me.side === "def") {
37 if (spike.defuse > 0) return `Defusing — keep holding F, ${secs(7 - spike.defuse)}s`;
38 return nearSpike
39 ? `Spike down, ${secs(spike.fuse)}s — hold F to defuse`
40 : `Spike down, ${secs(spike.fuse)}s — get to it and hold F`;
41 }
42 return `Spike down, ${secs(spike.fuse)}s — keep them off it`;
43 }
44
45 if (me.side === "atk") {
46 if (me.spike) return onSite ? "Hold F to plant" : "You have the spike — get to site A or B";
47 if (spike.carried) return "Your team has the spike — go with them and take a site";
48 if (nearSpike) return "Walk onto the spike to pick it up";
49 return "Somebody has to carry the spike — it is on the ground";
50 }
51 return `Defending — hold A and B, ${secs(snap.clock)}s`;
52 }
static/raycast.js added +170
@@ -0,0 +1,170 @@
1 /**
2 * The ray maths. Pure numbers in, pure numbers out: no canvas, no DOM, no
3 * state — so every decision the first-person view makes can be tested
4 * without a browser.
5 *
6 * The camera is a flat projection plane at `projection()` pixels from the
7 * eye. A column maps to an angle through that plane, not by sweeping the
8 * field of view evenly, which is what keeps straight walls straight. Depth
9 * is always the perpendicular distance to the plane: the fish-eye correction
10 * and the sprite z-clip both depend on the two being the same measure.
11 *
12 * Nothing here allocates. The DDA writes its answer into a result object the
13 * caller owns, so a 900-column frame allocates nothing at all.
14 */
15
16 export const FOV = (75 * Math.PI) / 180;
17
18 /** Pixels from the eye to the projection plane for a screen this wide. */
19 export const projection = (width, fov = FOV) => width / 2 / Math.tan(fov / 2);
20
21 /** The angle of the ray through screen column `col`, relative to the view. */
22 export function columnOffset(col, width, fov = FOV) {
23 const cameraX = (2 * (col + 0.5)) / width - 1;
24 return Math.atan(cameraX * Math.tan(fov / 2));
25 }
26
27 /** The world angle of the ray through screen column `col`. */
28 export const columnAngle = (col, width, view, fov = FOV) => view + columnOffset(col, width, fov);
29
30 /** The screen column a world angle falls on, fractional, off-screen allowed. */
31 export function angleColumn(offset, width, fov = FOV) {
32 return (width / 2) * (1 + Math.tan(offset) / Math.tan(fov / 2));
33 }
34
35 /** A wall `tall` units high at this depth covers this many pixels. */
36 export const sliceHeight = (perp, proj, tall) => (tall * proj) / Math.max(perp, 1e-6);
37
38 /** A fresh result object for `castRay` to fill. One per renderer, not per ray. */
39 export const hitResult = () => ({ dist: 0, side: 0, col: 0, row: 0, wall: false });
40
41 /**
42 * March a ray through the tile grid until it meets a solid tile.
43 *
44 * `solid(col, row)` answers for the grid; `out` is filled with the distance
45 * travelled, which side of the tile was struck (0 = an east or west face,
46 * 1 = a north or south face) and the tile itself. A ray that runs out of
47 * range reports `wall: false` at `max`.
48 */
49 export function castRay(solid, x, y, angle, tile, max, out) {
50 const dx = Math.cos(angle);
51 const dy = Math.sin(angle);
52 let col = Math.floor(x / tile);
53 let row = Math.floor(y / tile);
54 const deltaX = dx === 0 ? Infinity : Math.abs(tile / dx);
55 const deltaY = dy === 0 ? Infinity : Math.abs(tile / dy);
56 const stepX = dx < 0 ? -1 : 1;
57 const stepY = dy < 0 ? -1 : 1;
58 let sideX = dx === 0 ? Infinity : dx < 0 ? (x - col * tile) / -dx : ((col + 1) * tile - x) / dx;
59 let sideY = dy === 0 ? Infinity : dy < 0 ? (y - row * tile) / -dy : ((row + 1) * tile - y) / dy;
60
61 if (solid(col, row)) {
62 out.dist = 0;
63 out.side = 0;
64 out.col = col;
65 out.row = row;
66 out.wall = true;
67 return out;
68 }
69 for (;;) {
70 let dist;
71 let side;
72 if (sideX < sideY) {
73 dist = sideX;
74 col += stepX;
75 sideX += deltaX;
76 side = 0;
77 } else {
78 dist = sideY;
79 row += stepY;
80 sideY += deltaY;
81 side = 1;
82 }
83 if (dist > max) {
84 out.dist = max;
85 out.side = side;
86 out.col = col;
87 out.row = row;
88 out.wall = false;
89 return out;
90 }
91 if (solid(col, row)) {
92 out.dist = dist;
93 out.side = side;
94 out.col = col;
95 out.row = row;
96 out.wall = true;
97 return out;
98 }
99 }
100 }
101
102 /**
103 * How lit a wall slice is: one over the distance, floored so a far wall is
104 * dim rather than black, and a north–south face darker than an east–west one
105 * so a corner reads as a corner.
106 */
107 export function wallLight(perp, side, tile) {
108 const far = 1 / (1 + (perp / (tile * 7)) ** 2);
109 const face = side === 1 ? 0.62 : 1;
110 return Math.max(0.12, Math.min(1, 0.25 + 0.75 * far)) * face;
111 }
112
113 /** A floor or ceiling band dims the same way, by how far down the screen it is. */
114 export const bandLight = (rowFromHorizon, halfHeight) =>
115 Math.max(0.1, Math.min(1, rowFromHorizon / Math.max(1, halfHeight)));
116
117 /**
118 * Where a thing in the world lands on the screen.
119 *
120 * Returns the fractional column of its centre, its perpendicular depth and
121 * the pixel height of something `tall` units high there. `behind` is true
122 * when it is at or past the camera plane and must not be drawn at all.
123 */
124 export function project(px, py, view, sx, sy, width, proj, tall, out, fov = FOV) {
125 const dx = sx - px;
126 const dy = sy - py;
127 const dist = Math.hypot(dx, dy);
128 let offset = Math.atan2(dy, dx) - view;
129 while (offset > Math.PI) offset -= Math.PI * 2;
130 while (offset < -Math.PI) offset += Math.PI * 2;
131 const perp = dist * Math.cos(offset);
132 out.dist = dist;
133 out.offset = offset;
134 out.perp = perp;
135 out.behind = perp <= 1 || Math.abs(offset) > fov / 2 + 0.8;
136 out.column = out.behind ? -1 : angleColumn(offset, width, fov);
137 out.size = out.behind ? 0 : sliceHeight(perp, proj, tall);
138 return out;
139 }
140
141 export const projectResult = () => ({ dist: 0, offset: 0, perp: 0, behind: true, column: -1, size: 0 });
142
143 /**
144 * Is a sprite column in front of the wall drawn there? A sprite slightly
145 * nearer than the wall still counts as hidden by a hair of slack, so a body
146 * flush against a corner does not bleed through it.
147 */
148 export const spriteColumnVisible = (spritePerp, wallPerp) => spritePerp < wallPerp - 2;
149
150 /**
151 * How much smoke a ray crosses between the eye and `dist`.
152 *
153 * The chord of the circle that falls inside the segment, as a fraction of the
154 * circle's width: a ray that clips the edge of a smoke barely fogs, one that
155 * goes through the middle fogs completely. That is what makes a smoke read as
156 * a volume you are standing in rather than a disc painted on the floor.
157 */
158 export function smokeAlong(x, y, dx, dy, cx, cy, r, dist) {
159 const ox = x - cx;
160 const oy = y - cy;
161 const b = ox * dx + oy * dy;
162 const c = ox * ox + oy * oy - r * r;
163 const disc = b * b - c;
164 if (disc <= 0) return 0;
165 const root = Math.sqrt(disc);
166 const enter = Math.max(0, -b - root);
167 const leave = Math.min(dist, -b + root);
168 if (leave <= enter) return 0;
169 return Math.min(1, (leave - enter) / (r * 1.1));
170 }
static/roster.js added +30
@@ -0,0 +1,30 @@
1 /**
2 * Your five, always.
3 *
4 * Who is on your team and who is still standing must not depend on whether
5 * you can see them — for most of a round you cannot see anybody. Every team
6 * mate is in every snapshot already (the server shows you your own side), so
7 * this is a read of what is there: a fixed order, health clamped, and the
8 * dead marked.
9 */
10
11 /** The player's own team, in a stable order, ready to draw as rows. */
12 export function roster(snap) {
13 if (!snap || !snap.players) return [];
14 return snap.players
15 .filter((p) => p.mate)
16 .map((p) => ({
17 id: p.id,
18 name: p.name,
19 agent: p.agent,
20 hp: p.alive ? Math.max(0, Math.min(100, p.hp)) : 0,
21 alive: p.alive === true,
22 spike: p.spike === true,
23 mine: p.id === snap.me.id,
24 }))
25 .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
26 }
27
28 /** Enough of the roster to know when the panel has to be rebuilt. */
29 export const rosterKey = (rows) =>
30 rows.map((r) => `${r.id}:${r.agent}:${Math.round(r.hp)}:${r.alive ? 1 : 0}:${r.spike ? 1 : 0}`).join("|");
static/scene.js added +389
@@ -0,0 +1,389 @@
1 /**
2 * The world, drawn from inside the player's head.
3 *
4 * One pass of rays across the screen builds the walls and a depth buffer;
5 * everything else — bodies, the spike, ability zones, tracers — is a
6 * billboard tested against that buffer, so a body behind a corner is not
7 * drawn at all. Smoke is measured along each ray, not painted as a disc:
8 * cross one and everything past it fogs.
9 *
10 * The ray loop allocates nothing. Colours come out of a table built once,
11 * the depth and fog buffers are kept between frames, and the sprite list is
12 * sorted in a buffer the scene owns.
13 */
14
15 import {
16 castRay,
17 columnOffset,
18 hitResult,
19 project,
20 projection,
21 projectResult,
22 sliceHeight,
23 smokeAlong,
24 spriteColumnVisible,
25 wallLight,
26 } from "./raycast.js";
27
28 const EYE = 40; // half a tile up: the horizon sits on the middle of the wall
29 const WALL = 80; // walls are one tile tall
30 const BODY = 58; // a person, in world units
31 const MAX_SIGHT = 1800;
32 const LEVELS = 48;
33
34 export const AGENT_COLOUR = {
35 ember: "#f08a4b",
36 veil: "#8f7fe8",
37 sable: "#4fd6a0",
38 mender: "#e0c060",
39 rook: "#d96a8f",
40 };
41
42 // Wall colours, quantised into levels and built once, so the ray loop never
43 // builds a string. How dark a slice is comes from `wallLight`, which already
44 // knows about distance and which face was struck.
45 const SHADES = (() => {
46 const out = [];
47 for (let i = 0; i < LEVELS; i++) {
48 const k = (i + 0.5) / LEVELS;
49 out.push(`rgb(${Math.round(150 * k)},${Math.round(158 * k)},${Math.round(178 * k)})`);
50 }
51 return out;
52 })();
53 const FOG = "rgb(198,203,212)";
54
55 export function makeScene() {
56 return {
57 depth: new Float32Array(0),
58 fog: new Float32Array(0),
59 hit: hitResult(),
60 proj: projectResult(),
61 order: [],
62 smokes: [],
63 sky: null,
64 skyKey: "",
65 };
66 }
67
68 const shadeOf = (light) => SHADES[Math.min(LEVELS - 1, Math.max(0, (light * LEVELS) | 0))];
69
70 /**
71 * Draw one frame.
72 *
73 * `world` carries the map (`solid`, `tile`), `eye` is where the camera
74 * stands and looks, `snap` is the last snapshot the server sent, and `view`
75 * is the frame: its pixel size, and `look(player)` for the smoothed position
76 * to draw somebody at.
77 */
78 export function drawScene(ctx, scene, world, eye, snap, view) {
79 const W = view.width;
80 const H = view.height;
81 const step = W > 1100 ? 2 : 1;
82 const columns = Math.ceil(W / step);
83 if (scene.depth.length !== columns) {
84 scene.depth = new Float32Array(columns);
85 scene.fog = new Float32Array(columns);
86 }
87 const proj = projection(W);
88 const horizon = H / 2;
89
90 drawSky(ctx, scene, W, H, horizon);
91
92 const smokes = scene.smokes;
93 smokes.length = 0;
94 for (const zone of snap.zones) if ((zone.k === "smoke" || zone.k === "wall") && zone.f <= 0) smokes.push(zone);
95
96 const depth = scene.depth;
97 const fog = scene.fog;
98 const hit = scene.hit;
99 for (let i = 0; i < columns; i++) {
100 const offset = columnOffset(i * step + step / 2, W);
101 const angle = eye.angle + offset;
102 castRay(world.solid, eye.x, eye.y, angle, world.tile, MAX_SIGHT, hit);
103 const perp = Math.max(1, hit.dist * Math.cos(offset));
104 depth[i] = perp;
105 let haze = 0;
106 if (smokes.length) {
107 const dx = Math.cos(angle);
108 const dy = Math.sin(angle);
109 for (let s = 0; s < smokes.length; s++) {
110 const z = smokes[s];
111 haze += smokeAlong(eye.x, eye.y, dx, dy, z.x, z.y, z.r, hit.dist);
112 if (haze >= 1) break;
113 }
114 haze = Math.min(1, haze);
115 }
116 fog[i] = haze;
117 if (!hit.wall) continue;
118 const h = Math.min(H * 6, sliceHeight(perp, proj, WALL));
119 const top = horizon - h / 2;
120 ctx.fillStyle = shadeOf(wallLight(perp, hit.side, world.tile));
121 ctx.fillRect(i * step, top, step + 1, h);
122 if (haze > 0.01) {
123 ctx.globalAlpha = haze;
124 ctx.fillStyle = FOG;
125 ctx.fillRect(i * step, top, step + 1, h);
126 ctx.globalAlpha = 1;
127 }
128 }
129
130 drawThings(ctx, scene, world, eye, snap, view, proj, horizon, step, columns);
131 }
132
133 /** The two bands: a dark ceiling above the horizon, a warmer floor below. */
134 function drawSky(ctx, scene, W, H, horizon) {
135 const key = `${W}x${H}`;
136 if (scene.skyKey !== key || !scene.sky) {
137 const sky = ctx.createLinearGradient(0, 0, 0, H);
138 sky.addColorStop(0, "#0a0c11");
139 sky.addColorStop(0.49, "#23262f");
140 sky.addColorStop(0.5, "#3a3026");
141 sky.addColorStop(1, "#15120e");
142 scene.sky = sky;
143 scene.skyKey = key;
144 }
145 ctx.fillStyle = scene.sky;
146 ctx.fillRect(0, 0, W, H);
147 ctx.fillStyle = "rgba(255,255,255,0.09)";
148 ctx.fillRect(0, horizon - 1, W, 2);
149 }
150
151 /**
152 * Everything that is not a wall, far to near, each one clipped to the screen
153 * columns where it beats the wall behind it.
154 */
155 function drawThings(ctx, scene, world, eye, snap, view, proj, horizon, step, columns) {
156 const W = view.width;
157 const things = scene.order;
158 things.length = 0;
159
160 for (const zone of snap.zones) {
161 if (zone.k === "smoke") continue;
162 things.push({ kind: "zone", zone, x: zone.x, y: zone.y });
163 }
164 for (const orb of snap.orbs) things.push({ kind: "orb", x: orb.x, y: orb.y });
165 if (snap.spike.x !== null && !snap.spike.carried)
166 things.push({ kind: "spike", x: snap.spike.x, y: snap.spike.y });
167 for (const player of snap.players) {
168 if (player.id === snap.me.id) continue;
169 const at = view.look(player);
170 things.push({ kind: "body", player, x: at.x, y: at.y, angle: at.angle });
171 }
172
173 const p = scene.proj;
174 for (const thing of things) {
175 project(eye.x, eye.y, eye.angle, thing.x, thing.y, W, proj, BODY, p);
176 thing.perp = p.perp;
177 thing.column = p.column;
178 thing.size = p.size;
179 thing.behind = p.behind;
180 }
181 things.sort((a, b) => b.perp - a.perp);
182
183 for (const thing of things) {
184 if (thing.behind) continue;
185 const floorY = horizon + (EYE * proj) / thing.perp;
186 const haze = hazeAt(scene, thing.column, step, columns);
187 ctx.globalAlpha = Math.max(0, 1 - haze);
188 if (ctx.globalAlpha < 0.05) continue;
189 if (thing.kind === "body") drawBody(ctx, scene, thing, floorY, step, columns);
190 else if (thing.kind === "spike") drawSpike(ctx, scene, thing, floorY, snap, step, columns);
191 else if (thing.kind === "orb") drawBlob(ctx, scene, thing, floorY, "#c9a0ff", 0.35, step, columns);
192 else drawZone(ctx, scene, thing, floorY, proj, step, columns);
193 ctx.globalAlpha = 1;
194 }
195 ctx.globalAlpha = 1;
196
197 drawTracers(ctx, scene, eye, snap, W, proj, horizon);
198 }
199
200 const hazeAt = (scene, column, step, columns) => {
201 const i = Math.max(0, Math.min(columns - 1, Math.round(column / step)));
202 return scene.fog[i] ?? 0;
203 };
204
205 /**
206 * The visible runs of a sprite: the stretches of screen column where it is
207 * nearer than the wall. Two rectangles at most in practice, and the caller
208 * clips its drawing to each.
209 */
210 function withRuns(ctx, scene, left, right, perp, step, columns, draw) {
211 let runStart = -1;
212 const from = Math.max(0, Math.floor(left / step));
213 const to = Math.min(columns - 1, Math.ceil(right / step));
214 for (let i = from; i <= to + 1; i++) {
215 const shown = i <= to && spriteColumnVisible(perp, scene.depth[i]);
216 if (shown && runStart < 0) runStart = i;
217 if (!shown && runStart >= 0) {
218 ctx.save();
219 ctx.beginPath();
220 ctx.rect(runStart * step - 0.5, 0, (i - runStart) * step + 1, 1e5);
221 ctx.clip();
222 draw();
223 ctx.restore();
224 runStart = -1;
225 }
226 }
227 }
228
229 function drawBody(ctx, scene, thing, floorY, step, columns) {
230 const p = thing.player;
231 const h = thing.size;
232 const w = h * 0.46;
233 const cx = thing.column;
234 const top = floorY - h;
235 const mate = p.mate;
236
237 // The dead lie down. No tag, no chevron, no agent colour: if something is
238 // upright with a marker over it, it is alive and it can shoot you.
239 if (!p.alive) {
240 const lie = h * 0.16;
241 withRuns(ctx, scene, cx - w * 1.1, cx + w * 1.1, thing.perp, step, columns, () => {
242 ctx.fillStyle = "#4a4e57";
243 ctx.beginPath();
244 ctx.ellipse(cx, floorY - lie * 0.4, w * 1.05, lie, 0, 0, 7);
245 ctx.fill();
246 ctx.strokeStyle = "#23262d";
247 ctx.lineWidth = Math.max(1, h * 0.02);
248 ctx.stroke();
249 });
250 return;
251 }
252
253 const skin = mate ? "#3f9fd6" : "#d8483f";
254 const trim = AGENT_COLOUR[p.agent] || "#999";
255 const edge = Math.max(1.2, h * 0.05);
256
257 withRuns(ctx, scene, cx - w * 1.2, cx + w * 1.5, thing.perp, step, columns, () => {
258 // Body and head, each with a hard dark edge: the silhouette is what
259 // carries across a dim room, where the colour does not.
260 ctx.fillStyle = skin;
261 ctx.fillRect(cx - w / 2, top + h * 0.28, w, h * 0.72);
262 ctx.strokeStyle = "#0b0d11";
263 ctx.lineWidth = edge;
264 ctx.strokeRect(cx - w / 2, top + h * 0.28, w, h * 0.72);
265 ctx.fillStyle = trim;
266 ctx.fillRect(cx - w / 2, top + h * 0.34, w, h * 0.1);
267 ctx.beginPath();
268 ctx.arc(cx, top + h * 0.17, h * 0.15, 0, 7);
269 ctx.fillStyle = skin;
270 ctx.fill();
271 ctx.strokeStyle = "#0b0d11";
272 ctx.stroke();
273
274 if (p.spike) {
275 ctx.fillStyle = "#f0d060";
276 ctx.fillRect(cx + w * 0.5, top + h * 0.4, w * 0.3, h * 0.3);
277 }
278 if (p.hp < 100) {
279 ctx.fillStyle = "#000a";
280 ctx.fillRect(cx - w / 2, top - h * 0.1, w, h * 0.06);
281 ctx.fillStyle = mate ? "#6fcf6f" : "#e8b04a";
282 ctx.fillRect(cx - w / 2, top - h * 0.1, w * (p.hp / 100), h * 0.06);
283 }
284
285 if (mate) tag(ctx, p, cx, top, h);
286 else chevron(ctx, cx, top, h);
287 });
288 }
289
290 /** An enemy wears a chevron: a shape, not a hue, for anyone whose eyes read
291 * red and blue the same way. */
292 function chevron(ctx, cx, top, h) {
293 const size = Math.max(4, h * 0.16);
294 const y = top - h * 0.16;
295 ctx.beginPath();
296 ctx.moveTo(cx - size, y - size);
297 ctx.lineTo(cx + size, y - size);
298 ctx.lineTo(cx, y);
299 ctx.closePath();
300 ctx.fillStyle = "#ff6f5e";
301 ctx.fill();
302 ctx.lineWidth = Math.max(1, h * 0.02);
303 ctx.strokeStyle = "#140a08";
304 ctx.stroke();
305 }
306
307 /** A team mate wears their name and a pip of their health — never an enemy. */
308 function tag(ctx, p, cx, top, h) {
309 const size = Math.max(9, Math.min(18, h * 0.16));
310 const y = top - h * 0.34;
311 ctx.font = `${size}px system-ui, sans-serif`;
312 ctx.textAlign = "center";
313 const width = ctx.measureText(p.name).width + size * 1.4;
314 ctx.fillStyle = "rgba(10,12,16,0.65)";
315 ctx.fillRect(cx - width / 2, y, width, size * 1.4);
316 ctx.fillStyle = p.hp > 60 ? "#6fcf6f" : p.hp > 25 ? "#e0a040" : "#e05a4a";
317 ctx.fillRect(cx - width / 2 + size * 0.25, y + size * 0.35, size * 0.45, size * 0.7);
318 ctx.fillStyle = "#cfe8ff";
319 ctx.fillText(p.name, cx + size * 0.4, y + size * 1.05);
320 ctx.textAlign = "left";
321 }
322
323 function drawSpike(ctx, scene, thing, floorY, snap, step, columns) {
324 const h = thing.size * 0.42;
325 const cx = thing.column;
326 const planted = snap.spike.planted;
327 const blink = planted && Math.floor(Date.now() / 220) % 2 === 0;
328 withRuns(ctx, scene, cx - h, cx + h, thing.perp, step, columns, () => {
329 ctx.fillStyle = planted ? (blink ? "#ff5b4a" : "#7d2a22") : "#c8b45c";
330 ctx.fillRect(cx - h * 0.28, floorY - h, h * 0.56, h);
331 ctx.fillStyle = planted && blink ? "#ffd7a0" : "#2a2418";
332 ctx.fillRect(cx - h * 0.14, floorY - h * 0.85, h * 0.28, h * 0.22);
333 });
334 }
335
336 function drawBlob(ctx, scene, thing, floorY, colour, scale, step, columns) {
337 const r = thing.size * scale;
338 const cx = thing.column;
339 withRuns(ctx, scene, cx - r, cx + r, thing.perp, step, columns, () => {
340 ctx.fillStyle = colour;
341 ctx.beginPath();
342 ctx.arc(cx, floorY - r, r, 0, 7);
343 ctx.fill();
344 });
345 }
346
347 function drawZone(ctx, scene, thing, floorY, proj, step, columns) {
348 const z = thing.zone;
349 const wide = (z.r * proj) / thing.perp;
350 const cx = thing.column;
351 if (z.k === "wall") {
352 const h = (WALL * proj) / thing.perp;
353 withRuns(ctx, scene, cx - wide, cx + wide, thing.perp, step, columns, () => {
354 ctx.fillStyle = "rgba(120,200,255,0.45)";
355 ctx.fillRect(cx - wide, floorY - h, wide * 2, h);
356 ctx.strokeStyle = "rgba(180,225,255,0.8)";
357 ctx.strokeRect(cx - wide, floorY - h, wide * 2, h);
358 });
359 return;
360 }
361 const colour =
362 z.k === "molly" ? "rgba(235,120,50,0.6)" : z.k === "flash" ? "rgba(255,235,170,0.9)" : z.k === "heal" ? "rgba(120,230,140,0.55)" : z.k === "trip" ? "rgba(230,200,90,0.75)" : "rgba(90,220,170,0.5)";
363 withRuns(ctx, scene, cx - wide, cx + wide, thing.perp, step, columns, () => {
364 ctx.fillStyle = colour;
365 ctx.beginPath();
366 ctx.ellipse(cx, floorY, wide, Math.max(2, wide * 0.3), 0, 0, 7);
367 ctx.fill();
368 });
369 }
370
371 /** Shots the server let us see, as a line through the world. */
372 function drawTracers(ctx, scene, eye, snap, W, proj, horizon) {
373 if (!snap.shots.length) return;
374 const p = scene.proj;
375 ctx.lineWidth = 2;
376 for (const shot of snap.shots) {
377 project(eye.x, eye.y, eye.angle, shot.x1, shot.y1, W, proj, BODY, p);
378 if (p.behind) continue;
379 const x1 = p.column;
380 const y1 = horizon + (EYE * proj) / p.perp - p.size * 0.6;
381 project(eye.x, eye.y, eye.angle, shot.x2, shot.y2, W, proj, BODY, p);
382 if (p.behind) continue;
383 ctx.strokeStyle = shot.hit ? "rgba(255,140,120,0.5)" : "rgba(255,240,190,0.35)";
384 ctx.beginPath();
385 ctx.moveTo(x1, y1);
386 ctx.lineTo(p.column, horizon + (EYE * proj) / p.perp - p.size * 0.6);
387 ctx.stroke();
388 }
389 }
static/steer.js added +20
@@ -0,0 +1,20 @@
1 /**
2 * Keys to a direction on the map.
3 *
4 * The server has always been told a world-space direction, and that contract
5 * does not change: from inside the player's head W means "the way I am
6 * looking", so the keys are turned by the view angle before they are sent.
7 * In the top-down view the keys are the compass they always were.
8 */
9
10 /**
11 * `ax` is right on the keyboard (+1 for D), `ay` is down the screen (+1 for
12 * S, so forward is -1). `out` is filled and returned: no allocation.
13 */
14 export function moveVector(ax, ay, yaw, out = { x: 0, y: 0 }) {
15 const cos = Math.cos(yaw);
16 const sin = Math.sin(yaw);
17 out.x = cos * -ay - sin * ax;
18 out.y = sin * -ay + cos * ax;
19 return out;
20 }
static/topdown.js added +217
@@ -0,0 +1,217 @@
1 /**
2 * The board from above — the drawing this game started as.
3 *
4 * It is three things now: the minimap in the corner, the full-screen map Tab
5 * holds open, and the view a phone plays in, where a raycaster with touch
6 * look would be miserable. One function, drawn into whatever box it is given.
7 */
8
9 import { AGENT_COLOUR } from "./scene.js";
10
11 const TILE_COLOUR = {
12 "#": "#20242d",
13 A: "#2c2a22",
14 B: "#2c2a22",
15 a: "#2a2024",
16 d: "#1e2630",
17 ".": "#171a21",
18 };
19
20 /**
21 * Draw the whole map into `box` (canvas pixels). `look(player)` gives the
22 * smoothed position; `opts.labels` draws site letters and names, which only
23 * fit on the big version.
24 */
25 export function drawTopdown(ctx, map, snap, look, box, opts = {}) {
26 const cols = map.rows[0].length;
27 const s = box.w / (cols * map.tile);
28 const labels = opts.labels !== false;
29 ctx.save();
30 ctx.beginPath();
31 ctx.rect(box.x, box.y, box.w, box.h);
32 ctx.clip();
33 ctx.translate(box.x, box.y);
34
35 const t = map.tile * s;
36 for (let row = 0; row < map.rows.length; row++)
37 for (let col = 0; col < map.rows[row].length; col++) {
38 const cell = map.rows[row][col];
39 ctx.fillStyle = TILE_COLOUR[cell] ?? "#171a21";
40 ctx.fillRect(col * t, row * t, t + 0.6, t + 0.6);
41 if (labels && (cell === "A" || cell === "B")) {
42 ctx.fillStyle = "#6b5f3a";
43 ctx.font = `${Math.round(t * 0.5)}px system-ui, sans-serif`;
44 ctx.fillText(cell, col * t + t * 0.3, row * t + t * 0.68);
45 }
46 }
47
48 if (!snap) {
49 ctx.restore();
50 return;
51 }
52
53 for (const orb of snap.orbs) {
54 ctx.fillStyle = "#c9a0ff";
55 ctx.beginPath();
56 ctx.arc(orb.x * s, orb.y * s, Math.max(2, 9 * s), 0, 7);
57 ctx.fill();
58 }
59 for (const zone of snap.zones) if (zone.k !== "smoke") drawZone(ctx, zone, s);
60
61 if (snap.spike.x !== null) {
62 const x = snap.spike.x * s;
63 const y = snap.spike.y * s;
64 const r = Math.max(2.5, 7 * s);
65 ctx.fillStyle = snap.spike.planted ? (Math.floor(Date.now() / 250) % 2 ? "#ff5b4a" : "#8a2a22") : "#c8b45c";
66 ctx.fillRect(x - r, y - r, r * 2, r * 2);
67 if (snap.spike.planted && snap.spike.defuse > 0) {
68 ctx.fillStyle = "#4fd6a0";
69 ctx.fillRect(x - 20 * s, y + 12 * s, 40 * s * (snap.spike.defuse / 7), 3);
70 }
71 }
72
73 for (const shot of snap.shots) {
74 ctx.strokeStyle = shot.hit ? "rgba(255,120,110,0.85)" : "rgba(255,240,180,0.5)";
75 ctx.lineWidth = Math.max(1, 2 * s);
76 ctx.beginPath();
77 ctx.moveTo(shot.x1 * s, shot.y1 * s);
78 ctx.lineTo(shot.x2 * s, shot.y2 * s);
79 ctx.stroke();
80 }
81
82 for (const player of snap.players) drawPlayer(ctx, player, look(player), s, snap.me, labels);
83 // Smoke last: on this view it hides what is under it, as it should.
84 for (const zone of snap.zones) if (zone.k === "smoke" && zone.f <= 0) drawZone(ctx, zone, s);
85 ctx.restore();
86 }
87
88 function drawZone(ctx, z, s) {
89 const x = z.x * s;
90 const y = z.y * s;
91 const r = z.r * s;
92 ctx.lineWidth = Math.max(1, s);
93 if (z.k === "smoke") {
94 ctx.fillStyle = "rgba(190,195,205,0.72)";
95 disc(ctx, x, y, r, true);
96 } else if (z.k === "molly") {
97 ctx.fillStyle = "rgba(230,110,50,0.45)";
98 ctx.strokeStyle = "#f08a4b";
99 disc(ctx, x, y, r, true, true);
100 } else if (z.k === "wall") {
101 ctx.fillStyle = "rgba(120,200,255,0.55)";
102 disc(ctx, x, y, r, true);
103 } else if (z.k === "flash") {
104 ctx.fillStyle = z.f > 0 ? "#ffe9a8" : "rgba(255,255,255,0.9)";
105 disc(ctx, x, y, z.f > 0 ? 5 * s : 30 * s, true);
106 } else if (z.k === "dart") {
107 ctx.strokeStyle = "rgba(90,220,170,0.7)";
108 disc(ctx, x, y, r, false, true);
109 } else if (z.k === "trip") {
110 ctx.strokeStyle = "rgba(230,200,90,0.8)";
111 disc(ctx, x, y, r, false, true);
112 } else if (z.k === "heal") {
113 ctx.fillStyle = "rgba(120,230,140,0.5)";
114 disc(ctx, x, y, 14 * s, true);
115 }
116 }
117
118 function disc(ctx, x, y, r, fill, stroke) {
119 ctx.beginPath();
120 ctx.arc(x, y, r, 0, 7);
121 if (fill) ctx.fill();
122 if (stroke) ctx.stroke();
123 }
124
125 /**
126 * Everybody is a dot and you are an arrow — but a team mate is on the map
127 * whether you can see them or not, with their initial in the dot and a cross
128 * when they are down, and an enemy is only ever drawn while they are alive
129 * and in sight. The map stays honest about vision and legible about your own.
130 */
131 function drawPlayer(ctx, p, pos, s, me, labels) {
132 const mine = p.id === me.id;
133 if (!p.alive && !p.mate) return; // a corpse is not a threat and not a call-out
134 const x = pos.x * s;
135 const y = pos.y * s;
136 const r = Math.max(2.5, 16 * s);
137 const body = !p.alive ? "#555a63" : p.mate ? (mine ? "#eaeaea" : "#4fb3d9") : "#e35d5b";
138
139 if (mine && p.alive) {
140 ctx.save();
141 ctx.translate(x, y);
142 ctx.rotate(pos.angle);
143 ctx.fillStyle = body;
144 ctx.beginPath();
145 ctx.moveTo(r * 1.5, 0);
146 ctx.lineTo(-r, r * 0.9);
147 ctx.lineTo(-r * 0.4, 0);
148 ctx.lineTo(-r, -r * 0.9);
149 ctx.closePath();
150 ctx.fill();
151 ctx.restore();
152 return;
153 }
154
155 // The dead of your own team keep their place on the map, crossed out.
156 if (!p.alive) {
157 ctx.strokeStyle = "#6a707c";
158 ctx.lineWidth = Math.max(1, 2 * s);
159 ctx.beginPath();
160 ctx.moveTo(x - r * 0.8, y - r * 0.8);
161 ctx.lineTo(x + r * 0.8, y + r * 0.8);
162 ctx.moveTo(x + r * 0.8, y - r * 0.8);
163 ctx.lineTo(x - r * 0.8, y + r * 0.8);
164 ctx.stroke();
165 if (labels) {
166 ctx.fillStyle = "rgba(180,185,195,0.6)";
167 ctx.font = `${Math.round(11 * Math.max(1, s * 2))}px system-ui, sans-serif`;
168 ctx.textAlign = "center";
169 ctx.fillText(p.name, x, y - r * 1.9);
170 ctx.textAlign = "left";
171 }
172 return;
173 }
174
175 ctx.fillStyle = body;
176 ctx.beginPath();
177 ctx.arc(x, y, r, 0, 7);
178 ctx.fill();
179 ctx.strokeStyle = AGENT_COLOUR[p.agent] || "#888";
180 ctx.lineWidth = Math.max(1, 3 * s);
181 ctx.stroke();
182 ctx.strokeStyle = body;
183 ctx.beginPath();
184 ctx.moveTo(x, y);
185 ctx.lineTo(x + Math.cos(pos.angle) * r * 2, y + Math.sin(pos.angle) * r * 2);
186 ctx.stroke();
187
188 // The initial inside the dot, when the dot is big enough to hold one.
189 if (p.mate && r >= 6) {
190 ctx.fillStyle = "#0c1016";
191 ctx.font = `${Math.round(r * 1.2)}px system-ui, sans-serif`;
192 ctx.textAlign = "center";
193 ctx.fillText((p.name[0] || "?").toUpperCase(), x, y + r * 0.45);
194 ctx.textAlign = "left";
195 }
196 if (p.spike) {
197 ctx.fillStyle = "#f0d060";
198 ctx.fillRect(x - r * 0.4, y - r * 1.8, r * 0.8, r * 0.8);
199 }
200 if (p.blind) {
201 ctx.fillStyle = "rgba(255,255,255,0.8)";
202 disc(ctx, x, y - r * 1.6, r * 0.35, true);
203 }
204 if (labels) {
205 ctx.fillStyle = "rgba(235,235,235,0.75)";
206 ctx.font = `${Math.round(11 * Math.max(1, s * 2))}px system-ui, sans-serif`;
207 ctx.textAlign = "center";
208 ctx.fillText(p.name, x, y - r * 1.9);
209 ctx.textAlign = "left";
210 }
211 if (p.hp < 100) {
212 ctx.fillStyle = "#0008";
213 ctx.fillRect(x - r, y + r * 1.2, r * 2, 3);
214 ctx.fillStyle = "#6fcf6f";
215 ctx.fillRect(x - r, y + r * 1.2, r * 2 * (p.hp / 100), 3);
216 }
217 }
static/val.js +414 −215
@@ -3,25 +3,53 @@
33 * person does — nothing else. There is no position here the server did not
44 * hand over, and no enemy in a snapshot the server decided you cannot see,
55 * 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.
611 */
712 "use strict";
813
14 import { makeBeeper } from "./beep.js";
15 import { drawGun, gunSnapshot, gunTick, makeGun } from "./gun.js";
16 import { choosePrompt, REACH } from "./prompt.js";
17 import { roster, rosterKey } from "./roster.js";
18 import { AGENT_COLOUR, drawScene, makeScene } from "./scene.js";
19 import { moveVector } from "./steer.js";
20 import { drawTopdown } from "./topdown.js";
21
922 const $ = (id) => document.getElementById(id);
1023 const view = $("view");
1124 const ctx = view.getContext("2d");
1225
1326 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" };
27 const COARSE = matchMedia("(pointer: coarse)").matches;
28 const LOOK = 0.0022; // radians per pixel of mouse travel
29 const HELP_SEEN = "val.help.seen";
1530
1631 let map = null;
32 let solid = () => true;
1733 let snap = null;
1834 let socket = null;
35 let agents = null;
1936 const shown = new Map(); // smoothed positions, so 15 frames a second look like 60
2037 const keys = new Set();
21 let pointer = null; // aim target in canvas pixels
38 let yaw = 0; // where this person is looking, theirs to move between snapshots
39 let locked = false;
2240 let firing = false;
41 let mode = COARSE ? "top" : "fp"; // a raycaster with touch look is miserable
42 let mapOpen = false;
43 let pointer = null; // aim target in canvas pixels, top-down only
2344 let stick = null; // { id, ox, oy, mx, my }
2445 let aimTouch = null;
46 let helpShown = false;
47 let last = performance.now();
48
49 const walk = { x: 0, y: 0 };
50 const scene = makeScene();
51 const gun = makeGun();
52 const beeper = makeBeeper();
2553
2654 // ------------------------------------------------------------------ socket
2755
@@ -35,10 +63,13 @@ function connect() {
3563 };
3664 socket.onmessage = (event) => {
3765 const data = JSON.parse(event.data);
38 if (data.t === "s") {
39 snap = data;
40 paintHud(data);
41 }
66 if (data.t !== "s") return;
67 const first = snap === null;
68 snap = data;
69 if (first && mode === "fp") yaw = data.me.angle;
70 gunSnapshot(gun, data);
71 paintHud(data);
72 maybeHelp(data);
4273 };
4374 }
4475
@@ -48,8 +79,30 @@ const send = (message) => {
4879
4980 // ------------------------------------------------------------------- input
5081
82 function setMode(next) {
83 mode = next;
84 $("stage").dataset.mode = next;
85 if (next !== "fp" && document.pointerLockElement === view) document.exitPointerLock();
86 if (snap) yaw = snap.me.angle;
87 }
88
5189 addEventListener("keydown", (event) => {
5290 if (event.target instanceof HTMLInputElement) return;
91 if (event.code === "Tab") {
92 mapOpen = true;
93 event.preventDefault();
94 return;
95 }
96 if (event.code === "KeyH" || event.key === "?") {
97 toggleHelp();
98 event.preventDefault();
99 return;
100 }
101 if (event.code === "KeyV") {
102 setMode(mode === "fp" ? "top" : "fp");
103 event.preventDefault();
104 return;
105 }
53106 if (event.code === "KeyB") {
54107 toggleBuy();
55108 event.preventDefault();
@@ -61,30 +114,74 @@ addEventListener("keydown", (event) => {
61114 return;
62115 }
63116 keys.add(event.code);
64 if (["KeyW", "KeyA", "KeyS", "KeyD", "Space", "KeyF", "KeyR"].includes(event.code)) event.preventDefault();
117 if (["KeyW", "KeyA", "KeyS", "KeyD", "Space", "KeyF", "KeyR", "ArrowLeft", "ArrowRight"].includes(event.code))
118 event.preventDefault();
119 });
120 addEventListener("keyup", (event) => {
121 if (event.code === "Tab") mapOpen = false;
122 keys.delete(event.code);
123 });
124 addEventListener("blur", () => {
125 keys.clear();
126 mapOpen = false;
127 firing = false;
65128 });
66 addEventListener("keyup", (event) => keys.delete(event.code));
67 addEventListener("blur", () => keys.clear());
68129
69 view.addEventListener("pointermove", (event) => {
70 if (event.pointerType === "touch") return;
130 // Aiming is pointer lock: click the canvas to capture the mouse, move to
131 // look, Escape gives it back. The angle sent is the same angle as ever.
132 view.addEventListener("click", () => {
133 beeper.wake();
134 if (mode !== "fp" || locked) return;
135 if (!view.requestPointerLock) return setMode("top"); // no lock, no first person
136 // Asked for too soon after the last release, a browser rejects this; that
137 // is a shrug, not an error, and the hint stays up until it takes.
138 const asked = view.requestPointerLock();
139 if (asked && asked.catch) asked.catch(() => {});
140 });
141 document.addEventListener("pointerlockchange", () => {
142 locked = document.pointerLockElement === view;
143 if (!locked) firing = false;
144 $("stage").dataset.locked = locked ? "1" : "";
145 });
146 addEventListener("mousemove", (event) => {
147 if (locked) yaw += event.movementX * LOOK;
148 });
149 addEventListener("mousedown", (event) => {
150 if (locked && event.button === 0) firing = true;
151 });
152 addEventListener("mouseup", (event) => {
153 if (locked && event.button === 0) firing = false;
154 });
155
156 // The top-down view keeps the pointer aim it always had: it is the phone
157 // control scheme, and the fallback when the first-person view is not wanted.
158 const canvasPoint = (event) => {
71159 const box = view.getBoundingClientRect();
72 pointer = { x: ((event.clientX - box.left) / box.width) * view.width, y: ((event.clientY - box.top) / box.height) * view.height };
160 return {
161 x: ((event.clientX - box.left) / box.width) * view.width,
162 y: ((event.clientY - box.top) / box.height) * view.height,
163 };
164 };
165 view.addEventListener("pointermove", (event) => {
166 if (mode !== "top" || event.pointerType === "touch") return;
167 pointer = canvasPoint(event);
73168 });
74169 view.addEventListener("pointerdown", (event) => {
170 beeper.wake();
171 if (mode !== "top") return;
75172 view.setPointerCapture(event.pointerId);
76173 const box = view.getBoundingClientRect();
77 const x = event.clientX - box.left;
78174 if (event.pointerType === "touch") {
79 if (x < box.width / 2) stick = { id: event.pointerId, ox: event.clientX, oy: event.clientY, mx: 0, my: 0 };
175 if (event.clientX - box.left < box.width / 2)
176 stick = { id: event.pointerId, ox: event.clientX, oy: event.clientY, mx: 0, my: 0 };
80177 else {
81 aimTouch = { id: event.pointerId, x: ((event.clientX - box.left) / box.width) * view.width, y: ((event.clientY - box.top) / box.height) * view.height };
178 aimTouch = { id: event.pointerId, ...canvasPoint(event) };
82179 firing = true;
83180 }
84181 event.preventDefault();
85182 return;
86183 }
87 pointer = { x: ((event.clientX - box.left) / box.width) * view.width, y: ((event.clientY - box.top) / box.height) * view.height };
184 pointer = canvasPoint(event);
88185 firing = true;
89186 });
90187 view.addEventListener("pointermove", (event) => {
@@ -96,11 +193,7 @@ view.addEventListener("pointermove", (event) => {
96193 stick.mx = (dx / len) * reach;
97194 stick.my = (dy / len) * reach;
98195 }
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 }
196 if (aimTouch && event.pointerId === aimTouch.id) Object.assign(aimTouch, canvasPoint(event));
104197 });
105198 const endPointer = (event) => {
106199 if (stick && event.pointerId === stick.id) stick = null;
@@ -113,34 +206,37 @@ view.addEventListener("pointerup", endPointer);
113206 view.addEventListener("pointercancel", endPointer);
114207 view.addEventListener("contextmenu", (event) => event.preventDefault());
115208
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.
209 // Twenty times a second the server hears what this person is doing. In the
210 // first-person view the keys are read against the way you are facing; in the
211 // top-down view they are the compass, the way they always were.
129212 setInterval(() => {
130213 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;
214 let ax = 0;
215 let ay = 0;
216 if (keys.has("KeyW")) ay -= 1;
217 if (keys.has("KeyS")) ay += 1;
218 if (keys.has("KeyA")) ax -= 1;
219 if (keys.has("KeyD")) ax += 1;
137220 if (stick) {
138 mx = stick.mx;
139 my = stick.my;
221 ax = stick.mx;
222 ay = stick.my;
223 }
224 let mx = ax;
225 let my = ay;
226 let angle;
227 if (mode === "fp") {
228 if (keys.has("ArrowLeft")) yaw -= 0.045;
229 if (keys.has("ArrowRight")) yaw += 0.045;
230 angle = yaw;
231 moveVector(ax, ay, yaw, walk);
232 mx = walk.x;
233 my = walk.y;
234 } else {
235 const aim = aimTouch || pointer;
236 const me = worldToScreen(snap.me);
237 angle = aim ? Math.atan2(aim.y - me.y, aim.x - me.x) : snap.me.angle;
238 yaw = angle;
140239 }
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;
144240 send({
145241 t: "i",
146242 mx,
@@ -168,168 +264,69 @@ const worldToScreen = (p) => ({ x: p.x * scale(), y: p.y * scale() });
168264 function smooth(id, x, y, angle) {
169265 const now = shown.get(id);
170266 if (!now) {
171 shown.set(id, { x, y, angle });
172 return { x, y, angle };
267 const fresh = { x, y, angle };
268 shown.set(id, fresh);
269 return fresh;
173270 }
174271 now.x += (x - now.x) * 0.35;
175272 now.y += (y - now.y) * 0.35;
176 let turn = ((angle - now.angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI;
273 const turn = ((angle - now.angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI;
177274 now.angle += turn * 0.4;
178275 return now;
179276 }
180277
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 }
278 const look = (p) => smooth(p.id, p.x, p.y, p.angle);
196279
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() {
280 function frame(now) {
290281 requestAnimationFrame(frame);
282 const dt = Math.min(0.1, (now - last) / 1000);
283 last = now;
291284 if (!map) return;
292 const s = scale();
285 gunTick(gun, dt);
293286 ctx.clearRect(0, 0, view.width, view.height);
294 drawMap(s);
295 if (!snap) return;
296287
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();
288 if (!snap) {
289 drawTopdown(ctx, map, null, look, { x: 0, y: 0, w: view.width, h: view.height });
290 return;
302291 }
303 for (const zone of snap.zones) if (zone.k !== "smoke") drawZone(zone, s);
292 beeper.update(snap.spike.planted, snap.spike.fuse, now / 1000);
293 const eye = look(snap.me);
294 const facing = mode === "fp" ? yaw : eye.angle;
304295
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 }
296 if (mode === "fp" && !mapOpen) {
297 drawScene(ctx, scene, { solid, tile: map.tile }, { x: eye.x, y: eye.y, angle: facing }, snap, {
298 width: view.width,
299 height: view.height,
300 look,
301 });
302 drawGun(ctx, gun, view.width, view.height, snap);
303 drawMinimap();
304 } else {
305 drawTopdown(ctx, map, snap, look, { x: 0, y: 0, w: view.width, h: view.height });
314306 }
315307
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
329308 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");
309 else if (snap.spike.planted && snap.spike.defuse > 0 && snap.me.side === "def")
310 bar(snap.spike.defuse / 7, "#4fd6a0", "defusing");
331311 $("flash").style.opacity = Math.min(0.95, snap.me.blind * 0.8);
332312 $("dead").style.display = snap.me.alive ? "none" : "grid";
313 $("aimhint").style.display = mode === "fp" && !locked && !COARSE ? "grid" : "none";
314 }
315
316 /** The same top-down drawing, small, in the corner, with you as an arrow. */
317 function drawMinimap() {
318 const w = Math.min(view.width * 0.3, 260 * (view.width / 900));
319 const h = (w * map.rows.length) / map.rows[0].length;
320 const pad = 8;
321 ctx.save();
322 ctx.globalAlpha = 0.85;
323 ctx.fillStyle = "#0b0d11";
324 ctx.fillRect(pad - 2, pad - 2, w + 4, h + 4);
325 drawTopdown(ctx, map, snap, look, { x: pad, y: pad, w, h }, { labels: false });
326 ctx.restore();
327 ctx.strokeStyle = "rgba(255,255,255,0.18)";
328 ctx.lineWidth = 1;
329 ctx.strokeRect(pad - 2, pad - 2, w + 4, h + 4);
333330 }
334331
335332 function bar(fraction, colour, label) {
@@ -341,7 +338,7 @@ function bar(fraction, colour, label) {
341338 ctx.fillStyle = colour;
342339 ctx.fillRect(x, y, w * Math.min(1, fraction), 16);
343340 ctx.fillStyle = "#fff";
344 ctx.font = "12px system-ui";
341 ctx.font = "12px system-ui, sans-serif";
345342 ctx.textAlign = "center";
346343 ctx.fillText(label, view.width / 2, y + 12);
347344 ctx.textAlign = "left";
@@ -349,6 +346,50 @@ function bar(fraction, colour, label) {
349346
350347 // --------------------------------------------------------------------- hud
351348
349 // The buy opens itself every buy phase; closing it is remembered until the
350 // next one, so nobody has to fight the panel to watch the round start.
351 function toggleBuy() {
352 const open = $("buy").classList.toggle("on");
353 $("buy").dataset.closed = open ? "" : "1";
354 }
355 $("buytoggle").addEventListener("click", toggleBuy);
356 $("viewtoggle").addEventListener("click", () => setMode(mode === "fp" ? "top" : "fp"));
357 $("helptoggle").addEventListener("click", () => toggleHelp());
358 $("helpclose").addEventListener("click", () => toggleHelp(false));
359 for (const button of document.querySelectorAll("[data-buy]"))
360 button.addEventListener("click", () => send({ t: "buy", what: button.dataset.buy }));
361 for (const button of document.querySelectorAll("[data-agent]"))
362 button.addEventListener("click", () => send({ t: "agent", id: button.dataset.agent }));
363
364 function toggleHelp(force) {
365 const open = force === undefined ? $("help").style.display !== "grid" : force;
366 $("help").style.display = open ? "grid" : "none";
367 if (!open) {
368 helpShown = true;
369 try {
370 localStorage.setItem(HELP_SEEN, "1");
371 } catch {
372 // A browser that refuses storage still gets the card, every visit.
373 }
374 }
375 }
376
377 /** The first-run card, once per browser, and never over a round in progress. */
378 function maybeHelp(s) {
379 if (helpShown) return;
380 try {
381 if (localStorage.getItem(HELP_SEEN) === "1") {
382 helpShown = true;
383 return;
384 }
385 } catch {
386 /* no storage: show it and move on */
387 }
388 if (s.phase === "live" && s.me.alive) return;
389 helpShown = true;
390 $("help").style.display = "grid";
391 }
392
352393 const KITS = {};
353394
354395 function paintHud(s) {
@@ -364,28 +405,22 @@ function paintHud(s) {
364405 $("buy").classList.toggle("on", s.phase === "buy" && $("buy").dataset.closed !== "1");
365406 if (s.phase !== "buy") $("buy").dataset.closed = "";
366407
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 }
408 const text = choosePrompt(s, {
409 onSite: onSite(s.me),
410 nearSpike: s.spike.x !== null && Math.hypot(s.spike.x - s.me.x, s.spike.y - s.me.y) < REACH,
411 });
412 if ($("prompt").textContent !== text) $("prompt").textContent = text;
413
414 paintKit(s);
415 paintBuy(s);
416 paintRoster(s);
417 paintFeed(s);
383418
384419 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 = "";
420 const lines = s.events.slice(0, 3).join("\n");
421 if (log.dataset.text !== lines) {
422 log.dataset.text = lines;
423 log.replaceChildren();
389424 for (const line of s.events.slice(0, 3)) {
390425 const div = document.createElement("div");
391426 div.textContent = line;
@@ -399,7 +434,7 @@ function paintHud(s) {
399434 const key = rows.map((p) => `${p.name}${p.kills}${p.deaths}${p.alive}${p.credits}`).join();
400435 if (body.dataset.key === key) continue;
401436 body.dataset.key = key;
402 body.innerHTML = "";
437 body.replaceChildren();
403438 for (const player of rows) {
404439 const tr = document.createElement("tr");
405440 const cells = [
@@ -419,11 +454,175 @@ function paintHud(s) {
419454 }
420455 }
421456
422 fetch("/map.json")
423 .then((r) => r.json())
424 .then((data) => {
425 map = data;
457 /** Is this player standing where the spike arms? The grid says so. */
458 function onSite(me) {
459 if (!map) return false;
460 const row = map.rows[Math.floor(me.y / map.tile)];
461 const cell = row ? row[Math.floor(me.x / map.tile)] : "";
462 return cell === "A" || cell === "B";
463 }
464
465 /**
466 * What just happened to whom: newest first, and gone a few seconds later.
467 * The server sends names and a gun and nothing else, so there is nothing
468 * here to read a position out of.
469 */
470 function paintFeed(s) {
471 const box = $("feed");
472 const lines = (s.feed ?? []).slice().reverse();
473 // The age is bucketed into the key: an entry fades once on its way out
474 // rather than rebuilding the panel every frame.
475 const key = lines.map((l) => `${l.k}>${l.v}:${l.w}:${l.h}:${l.t > 6 ? "old" : "new"}`).join("|");
476 if (box.dataset.key === key) return;
477 box.dataset.key = key;
478 box.replaceChildren();
479 for (const line of lines) {
480 const row = document.createElement("div");
481 row.style.opacity = line.t > 6 ? "0.5" : "1";
482 const killer = document.createElement("span");
483 killer.className = "k";
484 killer.textContent = line.k ?? "—";
485 const victim = document.createElement("span");
486 victim.className = "v";
487 victim.textContent = line.v;
488 const how = document.createElement("span");
489 how.className = "w";
490 how.textContent = `${line.w ? " " + line.w : " ability"}${line.h ? " ✧" : ""}`;
491 const arrow = document.createElement("span");
492 arrow.textContent = " ▸ ";
493 row.append(killer, arrow, victim, how);
494 box.append(row);
495 }
496 }
497
498 /**
499 * Your team, always on screen: five slots, health, and struck through when
500 * somebody is dead. The one place both questions — who is with me, who is
501 * still up — are answered whether anybody is in sight or not.
502 */
503 function paintRoster(s) {
504 const rows = roster(s);
505 const panel = $("roster");
506 const key = rosterKey(rows);
507 if (panel.dataset.key === key) return;
508 panel.dataset.key = key;
509 panel.replaceChildren();
510 for (const row of rows) {
511 const slot = document.createElement("div");
512 slot.className = `slot${row.mine ? " mine" : ""}${row.alive ? "" : " down"}`;
513 slot.style.borderLeftColor = AGENT_COLOUR[row.agent] || "#4a5162";
514 const who = document.createElement("span");
515 who.className = "who";
516 who.textContent = row.name;
517 const agent = document.createElement("span");
518 agent.className = "agent";
519 agent.textContent = row.alive ? row.agent : `${row.agent} · dead`;
520 if (row.spike) {
521 const spike = document.createElement("span");
522 spike.className = "spike";
523 spike.textContent = " spike";
524 agent.append(spike);
525 }
526 const bar = document.createElement("span");
527 bar.className = "hp";
528 const fill = document.createElement("i");
529 fill.style.width = `${row.hp}%`;
530 fill.style.background = row.hp > 40 ? "#6fcf6f" : "#e0a040";
531 bar.append(fill);
532 slot.append(who, agent, bar);
533 panel.append(slot);
534 }
535 }
536
537 const kitOf = (id) => (agents ? agents.find((a) => a.id === id) : null);
538
539 /** The four ability buttons, each saying what it does and what it has left. */
540 function paintKit(s) {
541 const kit = $("kit");
542 const agent = kitOf(s.me.agent);
543 const shape =
544 ["c", "q", "e", "x"].map((slot) => `${slot}${s.me.charges[slot]}${Math.ceil(s.me.cooldown[slot])}`).join("|") +
545 s.me.agent +
546 s.me.ult +
547 (agent ? "k" : "");
548 if (KITS.shape === shape) return;
549 KITS.shape = shape;
550 kit.replaceChildren();
551 for (const slot of ["c", "q", "e", "x"]) {
552 const ability = agent ? agent.abilities[slot] : null;
553 const button = document.createElement("button");
554 const ready = slot === "x" ? s.me.ult >= s.me.ultNeeds : s.me.charges[slot] > 0 && s.me.cooldown[slot] <= 0;
555 button.disabled = !ready;
556 button.type = "button";
557 const count =
558 slot === "x"
559 ? `${s.me.ult}/${s.me.ultNeeds}`
560 : s.me.cooldown[slot] > 0
561 ? `${Math.ceil(s.me.cooldown[slot])}s`
562 : s.me.charges[slot];
563 const name = document.createElement("span");
564 name.textContent = `${slot.toUpperCase()}${ability ? " " + ability.name : ""}`;
565 const tally = document.createElement("b");
566 tally.textContent = String(count);
567 button.append(name, tally);
568 if (ability) button.title = `${ability.name} — ${ability.text}`;
569 button.addEventListener("click", () => send({ t: "i", c: slot }));
570 kit.append(button);
571 }
572 }
573
574 /** Prices on every button, greyed when you cannot afford it. */
575 function paintBuy(s) {
576 const agent = kitOf(s.me.agent);
577 for (const button of document.querySelectorAll("[data-buy]")) {
578 const what = button.dataset.buy;
579 let cost = Number(button.dataset.cost ?? 0);
580 if ((what === "c" || what === "q") && agent) {
581 const ability = agent.abilities[what];
582 cost = ability.cost;
583 button.replaceChildren();
584 const name = document.createElement("span");
585 name.textContent = `${what.toUpperCase()} ${ability.name}`;
586 const price = document.createElement("b");
587 price.textContent = `${ability.cost} · ${s.me.charges[what]}/${ability.charges}`;
588 button.append(name, price);
589 button.title = `${ability.text} (${ability.charges} charge${ability.charges === 1 ? "" : "s"} a round)`;
590 }
591 button.disabled = cost > s.me.credits;
592 }
593 for (const button of document.querySelectorAll("[data-agent]")) {
594 const picked = button.dataset.agent === s.me.agent;
595 button.style.outline = picked ? "2px solid #6fcf9f" : "";
596 }
597 }
598
599 // ------------------------------------------------------------------- start
600
601 Promise.all([fetch("/map.json").then((r) => r.json()), fetch("/agents.json").then((r) => r.json())])
602 .then(([mapData, kit]) => {
603 map = mapData;
604 agents = kit.agents;
605 const rows = map.rows;
606 const cols = rows[0].length;
607 solid = (col, row) =>
608 col < 0 || row < 0 || row >= rows.length || col >= cols || rows[row][col] === "#";
609 $("stage").dataset.mode = mode;
610 fillHelp();
426611 resize();
427612 connect();
428 });
613 })
614 .catch(() => ($("conn").textContent = "offline"));
615
616 /** The card says the round, the sides, the keys and the kit, in that order. */
617 function fillHelp() {
618 const list = $("helpagents");
619 if (!list || !agents) return;
620 list.replaceChildren();
621 for (const agent of agents) {
622 const li = document.createElement("li");
623 li.textContent = `${agent.name} (${agent.role}) — ${agent.blurb}`;
624 list.append(li);
625 }
626 }
627
429628 requestAnimationFrame(frame);
↑↓ navigate ↵ open esc close

Keyboard shortcuts