benjivers/val Public Public: anyone can read this; the crew can change it.

Draw the match from inside the player's head

Nobody could tell what they were looking at: a board of dots does not read
as a site you are holding. This puts the camera in the player's eyes with a
raycaster over the tile grid the map was already built for — one ray a
column, a wall slice inversely tall, shaded by distance and by which face
was struck so a corner reads as a corner.

The client is split into modules named for one concept so the maths can be
tested without a browser: raycast.js (columns, slices, z-clip, smoke along a
ray), scene.js (the world), topdown.js (the old view, now the minimap and
the phone view), gun.js, beep.js, steer.js, prompt.js. Bun bundles them at
start-up into the one content-named file the page has always loaded, so the
CSP and the year-long cache are untouched and no dependency is added.

Bodies are billboards z-clipped against the wall depth, team mates named and
enemies never; smoke fogs a ray from where it enters it; the spike is on the
ground with a beep that quickens; there is a crosshair, a weapon that kicks
and a hit marker. Aiming is pointer lock and the angle sent is the same
angle as before — match.ts and arena.ts are untouched. V falls back to the
top-down view, Tab holds the map open, and a coarse pointer stays top-down.

Instructions come with it: a first-run card over the canvas (dismissed per
browser, never over a live round, H or ? reopens), one contextual prompt at
a time, and a buy panel that prices every button and says what each ability
does. /agents.json is new — the client cannot label a kit it does not know.

Co-Authored-By: Claude Opus 5 <[email protected]>
Wren committed 52f9b27 Browse files
17 files changed +1934 −227
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/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/pages/game.ts +54 −8
@@ -70,6 +70,22 @@ 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; }
7389 #hud { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; margin: .5rem 0;
7490 font-variant-numeric: tabular-nums; }
7591 .chip { background: #1b1e25; border: 1px solid #2a2f3a; border-radius: .4rem; padding: .3rem .55rem;
@@ -118,7 +134,30 @@ export function gamePage(opts: {
118134 <div id="stage">
119135 <canvas id="view" width="900" height="570"></canvas>
120136 <div id="flash"></div>
137 <div id="aimhint">click to aim · esc releases</div>
121138 <div id="dead">waiting for the next round</div>
139 <div id="prompt">Click the map to aim, then press <b>H</b> for how to play.</div>
140 <div id="help" role="dialog" aria-label="How to play val"><div>
141 <h2>How to play</h2>
142 <p><strong>The round.</strong> Attackers carry the spike to site A or B and plant it;
143 defenders stop them, or defuse it once it is down. Plant and it runs a 45s fuse.
144 First team to 13 rounds wins, and the sides swap at 12.</p>
145 <p><strong>The teams.</strong> You are <span class="red">red</span> or
146 <span class="blue">blue</span>; each round one team attacks and the other defends.
147 Team mates carry a name tag over their head, enemies never do.</p>
148 <p><strong>Controls.</strong> <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> move ·
149 click the view to capture the mouse and look, <kbd>Esc</kbd> releases ·
150 click to fire · <kbd>R</kbd> reload · hold <kbd>F</kbd> to plant, defuse or pick up ·
151 <kbd>Tab</kbd> holds the big map open · <kbd>V</kbd> swaps to the top-down view ·
152 <kbd>B</kbd> opens the buy · <kbd>H</kbd> brings this card back.</p>
153 <p><strong>Abilities.</strong> <kbd>C</kbd> and <kbd>Q</kbd> are bought in the buy phase and
154 last the round, <kbd>E</kbd> is free and comes back on a cooldown, and <kbd>X</kbd> is
155 the ultimate, paid for in ult points you earn by playing.</p>
156 <p><strong>On a phone</strong> the view stays top-down: the left thumb walks, the right
157 thumb aims and fires, and the buttons under the map buy and cast.</p>
158 <ul id="helpagents"></ul>
159 <button id="helpclose" type="button">Got it — play</button>
160 </div></div>
122161 </div>
123162
124163 <div id="hud">
@@ -128,6 +167,8 @@ export function gamePage(opts: {
128167 <span class="chip" id="ultmeter">ult 0/6</span>
129168 <span class="kit" id="kit"></span>
130169 <button id="buytoggle" class="chip" type="button">buy (B)</button>
170 <button id="viewtoggle" class="chip" type="button">view (V)</button>
171 <button id="helptoggle" class="chip" type="button">how to play (H)</button>
131172 </div>
132173
133174 <div id="buy">
@@ -136,13 +177,16 @@ export function gamePage(opts: {
136177 (a) => `<button type="button" data-agent="${escape(a.id)}" title="${escape(a.blurb)}">${escape(a.name)} <small>${escape(a.role)}</small></button>`,
137178 ).join("")}</div>
138179 <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>`)
180 .map(
181 (id) =>
182 `<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>`,
183 )
140184 .join("")}</div>
141185 <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>
186 <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>
187 <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>
188 <button type="button" data-buy="c" data-cost="0">C ability <b>—</b></button>
189 <button type="button" data-buy="q" data-cost="0">Q ability <b>—</b></button>
146190 </div>
147191 </div>
148192
@@ -162,9 +206,11 @@ export function gamePage(opts: {
162206 </div>
163207
164208 <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>
209 <p><strong>Controls.</strong> WASD move · click the view to capture the mouse, move to look,
210 Esc releases · click fires · R reloads · <strong>F</strong> plants and defuses (hold it) ·
211 C / Q / E / X cast · Tab holds the map open · V swaps to the top-down view ·
212 B opens the buy · H shows how to play.
213 On a phone it stays top-down: left thumb walks, right thumb aims and fires.</p>
168214 <p>Five attackers carry the spike, five defenders stop them. 100s round, 4s plant, 45s fuse,
169215 7s defuse with a checkpoint at half. First to 13, sides swap at 12. Bots fill every seat
170216 nobody is in, and they use their kit.</p>
src/prompt.test.ts added +79
@@ -0,0 +1,79 @@
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("carrying the spike off a site sends you to one", () => {
22 expect(choosePrompt(snapshot({}, { spike: true }), { onSite: false })).toBe(
23 "You have the spike — get to site A or B",
24 );
25 });
26
27 test("carrying it onto a site says how to plant", () => {
28 expect(choosePrompt(snapshot({}, { spike: true }), { onSite: true })).toBe("Hold F to plant");
29 });
30
31 test("a defender near a planted spike is told to defuse, with the fuse", () => {
32 const text = choosePrompt(snapshot({}, { side: "def" }, { planted: true, fuse: 31.4 }), { nearSpike: true });
33 expect(text).toBe("Spike down, 32s — hold F to defuse");
34 });
35
36 test("a defender across the map is told to get to it", () => {
37 const text = choosePrompt(snapshot({}, { side: "def" }, { planted: true, fuse: 31.4 }), { nearSpike: false });
38 expect(text).toBe("Spike down, 32s — get to it and hold F");
39 });
40
41 test("an attacker with it planted is told to hold the site", () => {
42 expect(choosePrompt(snapshot({}, {}, { planted: true, fuse: 10 }))).toBe(
43 "Spike down, 10s — keep them off it",
44 );
45 });
46
47 test("blind beats everything else on screen", () => {
48 const text = choosePrompt(snapshot({}, { blind: 1.4, spike: true }), { onSite: true });
49 expect(text).toBe("You are blind — back off until it clears");
50 });
51
52 test("dead in a live round says you are still in the match", () => {
53 expect(choosePrompt(snapshot({}, { alive: false }))).toBe("Waiting for the next round — you're in it");
54 });
55
56 test("the end of a round shows the result and the countdown", () => {
57 const text = choosePrompt(snapshot({ phase: "end", clock: 4.1, result: "Spike defused" }));
58 expect(text).toBe("Spike defused — next round in 5s");
59 });
60
61 test("a live defender is told what defending is", () => {
62 expect(choosePrompt(snapshot({}, { side: "def" }))).toBe("Defending — hold A and B, 40s");
63 });
64
65 test("an attacker beside a loose spike is told to walk onto it", () => {
66 expect(choosePrompt(snapshot(), { nearSpike: true })).toBe("Walk onto the spike to pick it up");
67 });
68
69 test("defusing counts down the defuse, not the fuse", () => {
70 const text = choosePrompt(snapshot({}, { side: "def" }, { planted: true, fuse: 20, defuse: 3.5 }), {
71 nearSpike: true,
72 });
73 expect(text).toBe("Defusing — keep holding F, 4s");
74 });
75
76 test("no snapshot yet is no prompt", () => {
77 expect(choosePrompt(null as unknown as Snap)).toBe("");
78 });
79 });
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/scene.test.ts added +174
@@ -0,0 +1,174 @@
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("a smoke between the eye and a body hides it", () => {
121 const smoke = [{ k: "smoke", x: 200, y: 200, r: 90, f: 0, l: 8 }];
122 const ctx = fakeContext();
123 drawScene(ctx as never, makeScene(), world, eye, snapshot([body()], smoke), view);
124 expect(ctx.ops.some((o) => o.op === "arc")).toBe(false);
125
126 // A smoke that is not on the line does not hide anything.
127 const aside = [{ k: "smoke", x: 200, y: 900, r: 90, f: 0, l: 8 }];
128 const clear = fakeContext();
129 drawScene(clear as never, makeScene(), world, eye, snapshot([body()], aside), view);
130 expect(clear.ops.some((o) => o.op === "arc")).toBe(true);
131 });
132
133 test("every kind of zone, the spike and a tracer draw without falling over", () => {
134 const zones = ["smoke", "molly", "wall", "trip", "dart", "flash", "heal"].map((k, i) => ({
135 k,
136 x: 300 + i * 20,
137 y: 200,
138 r: 90,
139 f: k === "flash" ? 0.3 : 0,
140 l: 5,
141 }));
142 const snap = snapshot([body(), body({ id: "mate", mate: true, alive: false, hp: 0 })], zones);
143 snap.orbs = [{ x: 360, y: 200 }] as never;
144 snap.shots = [{ x1: 130, y1: 200, x2: 380, y2: 200, hit: true, age: 0 }] as never;
145 snap.spike = { planted: true, carried: false, fuse: 12, defuse: 0, x: 330, y: 200 } as never;
146 const ctx = fakeContext();
147 drawScene(ctx as never, makeScene(), world, eye, snap, view);
148 expect(ctx.ops.length).toBeGreaterThan(100);
149 expect(ctx.globalAlpha).toBe(1);
150 });
151
152 test("the top-down view draws the real map, small or large", () => {
153 const map = { rows: gridRows(), tile: TILE };
154 const snap = snapshot([body({ mate: true })]);
155 const big = fakeContext();
156 drawTopdown(big as never, map, snap, (p: never) => p, { x: 0, y: 0, w: 900, h: 570 });
157 const small = fakeContext();
158 drawTopdown(small as never, map, snap, (p: never) => p, { x: 8, y: 8, w: 260, h: 165 }, { labels: false });
159 expect(big.ops.some((o) => o.op === "fillText")).toBe(true);
160 expect(small.ops.some((o) => o.op === "fillText")).toBe(false);
161 expect(small.ops.some((o) => o.op === "translate")).toBe(true);
162 });
163
164 test("the gun and the crosshair draw, and the hit marker only when there is one", () => {
165 const gun = makeGun();
166 const ctx = fakeContext();
167 drawGun(ctx as never, gun, 900, 570, snapshot([]));
168 const quiet = ctx.ops.length;
169 gun.mark = 1;
170 gun.kick = 1;
171 const loud = fakeContext();
172 drawGun(loud as never, gun, 900, 570, snapshot([]));
173 expect(loud.ops.length).toBeGreaterThan(quiet);
174 });
src/server.test.ts +25
@@ -176,3 +176,28 @@ test("the match keeps running and is never parked", () => {
176176 expect(state.now).toBeGreaterThan(before);
177177 expect(["buy", "live", "end", "over"]).toContain(state.phase);
178178 });
179
180 test("the kit is public: every ability, its price and what it does", async () => {
181 const response = await fetch(base + "/agents.json");
182 expect(response.status).toBe(200);
183 expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
184 const body = (await response.json()) as { agents: { id: string; abilities: Record<string, { name: string; cost: number; text: string }> }[] };
185 expect(body.agents.length).toBe(5);
186 const veil = body.agents.find((a) => a.id === "veil")!;
187 expect(veil.abilities.c.name).toBe("Cloudburst");
188 expect(veil.abilities.c.cost).toBe(100);
189 expect(veil.abilities.e.text).toContain("smoke");
190 // The ultimate is never for sale, and no server-only state leaks out.
191 expect(veil.abilities.x.cost).toBe(0);
192 expect(JSON.stringify(body)).not.toContain("kind");
193 });
194
195 test("the page carries the first-run card and the prompt line", async () => {
196 const body = await (await fetch(base + "/")).text();
197 expect(body).toContain('id="help"');
198 expect(body).toContain("How to play");
199 expect(body).toContain('id="prompt"');
200 // Every buy button says what it costs, so the panel is readable at a glance.
201 expect(body).toContain('data-buy="vandal" data-cost="2900"');
202 expect(body).toContain('id="viewtoggle"');
203 });
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 });
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 +50
@@ -0,0 +1,50 @@
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
22 if (snap.phase === "over") return snap.result ? `Match over — ${snap.result}` : "Match over";
23 if (me.blind > 0.05) return "You are blind — back off until it clears";
24 if (!me.alive)
25 return snap.phase === "buy"
26 ? "Buy phase — you are back next round"
27 : "Waiting for the next round — you're in it";
28 if (snap.phase === "end") return snap.result ? `${snap.result} — next round in ${secs(snap.clock)}s` : "Round over";
29 if (snap.phase === "buy")
30 return `Buy phase — ${secs(snap.clock)}s left. Click a gun you can afford.`;
31
32 if (me.planting > 0) return `Planting — hold F for ${secs(4 - me.planting)}s more`;
33 if (spike.planted) {
34 if (me.side === "def") {
35 if (spike.defuse > 0) return `Defusing — keep holding F, ${secs(7 - spike.defuse)}s`;
36 return nearSpike
37 ? `Spike down, ${secs(spike.fuse)}s — hold F to defuse`
38 : `Spike down, ${secs(spike.fuse)}s — get to it and hold F`;
39 }
40 return `Spike down, ${secs(spike.fuse)}s — keep them off it`;
41 }
42
43 if (me.side === "atk") {
44 if (me.spike) return onSite ? "Hold F to plant" : "You have the spike — get to site A or B";
45 if (spike.carried) return "Your team has the spike — go with them and take a site";
46 if (nearSpike) return "Walk onto the spike to pick it up";
47 return "Somebody has to carry the spike — it is on the ground";
48 }
49 return `Defending — hold A and B, ${secs(snap.clock)}s`;
50 }
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/scene.js added +344
@@ -0,0 +1,344 @@
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 const skin = !p.alive ? "#555a63" : mate ? "#3f9fd6" : "#d8483f";
237 const trim = AGENT_COLOUR[p.agent] || "#999";
238
239 withRuns(ctx, scene, cx - w, cx + w * 1.4, thing.perp, step, columns, () => {
240 // Body, head, and a shoulder band in the agent's colour.
241 ctx.fillStyle = skin;
242 ctx.fillRect(cx - w / 2, top + h * 0.28, w, h * 0.72);
243 ctx.fillStyle = trim;
244 ctx.fillRect(cx - w / 2, top + h * 0.34, w, h * 0.1);
245 ctx.beginPath();
246 ctx.arc(cx, top + h * 0.17, h * 0.15, 0, 7);
247 ctx.fillStyle = skin;
248 ctx.fill();
249 ctx.lineWidth = Math.max(1, h * 0.03);
250 ctx.strokeStyle = trim;
251 ctx.stroke();
252 if (!p.alive) return;
253 if (p.spike) {
254 ctx.fillStyle = "#f0d060";
255 ctx.fillRect(cx + w * 0.5, top + h * 0.4, w * 0.3, h * 0.3);
256 }
257 if (p.hp < 100) {
258 ctx.fillStyle = "#000a";
259 ctx.fillRect(cx - w / 2, top - h * 0.1, w, h * 0.06);
260 ctx.fillStyle = mate ? "#6fcf6f" : "#e8b04a";
261 ctx.fillRect(cx - w / 2, top - h * 0.1, w * (p.hp / 100), h * 0.06);
262 }
263 // A name tag over team mates only: an enemy is a shape, never a label.
264 if (mate) {
265 const size = Math.max(9, Math.min(18, h * 0.16));
266 ctx.font = `${size}px system-ui, sans-serif`;
267 ctx.textAlign = "center";
268 ctx.fillStyle = "rgba(10,12,16,0.65)";
269 const tw = ctx.measureText(p.name).width + 8;
270 ctx.fillRect(cx - tw / 2, top - h * 0.34, tw, size * 1.4);
271 ctx.fillStyle = "#cfe8ff";
272 ctx.fillText(p.name, cx, top - h * 0.34 + size * 1.05);
273 ctx.textAlign = "left";
274 }
275 });
276 }
277
278 function drawSpike(ctx, scene, thing, floorY, snap, step, columns) {
279 const h = thing.size * 0.42;
280 const cx = thing.column;
281 const planted = snap.spike.planted;
282 const blink = planted && Math.floor(Date.now() / 220) % 2 === 0;
283 withRuns(ctx, scene, cx - h, cx + h, thing.perp, step, columns, () => {
284 ctx.fillStyle = planted ? (blink ? "#ff5b4a" : "#7d2a22") : "#c8b45c";
285 ctx.fillRect(cx - h * 0.28, floorY - h, h * 0.56, h);
286 ctx.fillStyle = planted && blink ? "#ffd7a0" : "#2a2418";
287 ctx.fillRect(cx - h * 0.14, floorY - h * 0.85, h * 0.28, h * 0.22);
288 });
289 }
290
291 function drawBlob(ctx, scene, thing, floorY, colour, scale, step, columns) {
292 const r = thing.size * scale;
293 const cx = thing.column;
294 withRuns(ctx, scene, cx - r, cx + r, thing.perp, step, columns, () => {
295 ctx.fillStyle = colour;
296 ctx.beginPath();
297 ctx.arc(cx, floorY - r, r, 0, 7);
298 ctx.fill();
299 });
300 }
301
302 function drawZone(ctx, scene, thing, floorY, proj, step, columns) {
303 const z = thing.zone;
304 const wide = (z.r * proj) / thing.perp;
305 const cx = thing.column;
306 if (z.k === "wall") {
307 const h = (WALL * proj) / thing.perp;
308 withRuns(ctx, scene, cx - wide, cx + wide, thing.perp, step, columns, () => {
309 ctx.fillStyle = "rgba(120,200,255,0.45)";
310 ctx.fillRect(cx - wide, floorY - h, wide * 2, h);
311 ctx.strokeStyle = "rgba(180,225,255,0.8)";
312 ctx.strokeRect(cx - wide, floorY - h, wide * 2, h);
313 });
314 return;
315 }
316 const colour =
317 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)";
318 withRuns(ctx, scene, cx - wide, cx + wide, thing.perp, step, columns, () => {
319 ctx.fillStyle = colour;
320 ctx.beginPath();
321 ctx.ellipse(cx, floorY, wide, Math.max(2, wide * 0.3), 0, 0, 7);
322 ctx.fill();
323 });
324 }
325
326 /** Shots the server let us see, as a line through the world. */
327 function drawTracers(ctx, scene, eye, snap, W, proj, horizon) {
328 if (!snap.shots.length) return;
329 const p = scene.proj;
330 ctx.lineWidth = 2;
331 for (const shot of snap.shots) {
332 project(eye.x, eye.y, eye.angle, shot.x1, shot.y1, W, proj, BODY, p);
333 if (p.behind) continue;
334 const x1 = p.column;
335 const y1 = horizon + (EYE * proj) / p.perp - p.size * 0.6;
336 project(eye.x, eye.y, eye.angle, shot.x2, shot.y2, W, proj, BODY, p);
337 if (p.behind) continue;
338 ctx.strokeStyle = shot.hit ? "rgba(255,140,120,0.5)" : "rgba(255,240,190,0.35)";
339 ctx.beginPath();
340 ctx.moveTo(x1, y1);
341 ctx.lineTo(p.column, horizon + (EYE * proj) / p.perp - p.size * 0.6);
342 ctx.stroke();
343 }
344 }
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 +184
@@ -0,0 +1,184 @@
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 /** Everybody is a dot; you are an arrow, so you can tell at a glance where you face. */
126 function drawPlayer(ctx, p, pos, s, me, labels) {
127 const x = pos.x * s;
128 const y = pos.y * s;
129 const r = Math.max(2.5, 16 * s);
130 const mine = p.id === me.id;
131 const body = !p.alive ? "#555a63" : p.mate ? (mine ? "#eaeaea" : "#4fb3d9") : "#e35d5b";
132
133 if (mine) {
134 ctx.save();
135 ctx.translate(x, y);
136 ctx.rotate(pos.angle);
137 ctx.fillStyle = body;
138 ctx.beginPath();
139 ctx.moveTo(r * 1.5, 0);
140 ctx.lineTo(-r, r * 0.9);
141 ctx.lineTo(-r * 0.4, 0);
142 ctx.lineTo(-r, -r * 0.9);
143 ctx.closePath();
144 ctx.fill();
145 ctx.restore();
146 return;
147 }
148
149 ctx.fillStyle = body;
150 ctx.beginPath();
151 ctx.arc(x, y, r, 0, 7);
152 ctx.fill();
153 ctx.strokeStyle = AGENT_COLOUR[p.agent] || "#888";
154 ctx.lineWidth = Math.max(1, 3 * s);
155 ctx.stroke();
156 if (p.alive) {
157 ctx.strokeStyle = body;
158 ctx.beginPath();
159 ctx.moveTo(x, y);
160 ctx.lineTo(x + Math.cos(pos.angle) * r * 2, y + Math.sin(pos.angle) * r * 2);
161 ctx.stroke();
162 }
163 if (p.spike) {
164 ctx.fillStyle = "#f0d060";
165 ctx.fillRect(x - r * 0.4, y - r * 1.8, r * 0.8, r * 0.8);
166 }
167 if (p.blind) {
168 ctx.fillStyle = "rgba(255,255,255,0.8)";
169 disc(ctx, x, y - r * 1.6, r * 0.35, true);
170 }
171 if (labels) {
172 ctx.fillStyle = "rgba(235,235,235,0.75)";
173 ctx.font = `${Math.round(11 * Math.max(1, s * 2))}px system-ui, sans-serif`;
174 ctx.textAlign = "center";
175 ctx.fillText(p.name, x, y - r * 1.9);
176 ctx.textAlign = "left";
177 }
178 if (p.alive && p.hp < 100) {
179 ctx.fillStyle = "#0008";
180 ctx.fillRect(x - r, y + r * 1.2, r * 2, 3);
181 ctx.fillStyle = "#6fcf6f";
182 ctx.fillRect(x - r, y + r * 1.2, r * 2 * (p.hp / 100), 3);
183 }
184 }
static/val.js +334 −215
@@ -3,25 +3,52 @@
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 { drawScene, makeScene } from "./scene.js";
18 import { moveVector } from "./steer.js";
19 import { drawTopdown } from "./topdown.js";
20
921 const $ = (id) => document.getElementById(id);
1022 const view = $("view");
1123 const ctx = view.getContext("2d");
1224
1325 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" };
26 const COARSE = matchMedia("(pointer: coarse)").matches;
27 const LOOK = 0.0022; // radians per pixel of mouse travel
28 const HELP_SEEN = "val.help.seen";
1529
1630 let map = null;
31 let solid = () => true;
1732 let snap = null;
1833 let socket = null;
34 let agents = null;
1935 const shown = new Map(); // smoothed positions, so 15 frames a second look like 60
2036 const keys = new Set();
21 let pointer = null; // aim target in canvas pixels
37 let yaw = 0; // where this person is looking, theirs to move between snapshots
38 let locked = false;
2239 let firing = false;
40 let mode = COARSE ? "top" : "fp"; // a raycaster with touch look is miserable
41 let mapOpen = false;
42 let pointer = null; // aim target in canvas pixels, top-down only
2343 let stick = null; // { id, ox, oy, mx, my }
2444 let aimTouch = null;
45 let helpShown = false;
46 let last = performance.now();
47
48 const walk = { x: 0, y: 0 };
49 const scene = makeScene();
50 const gun = makeGun();
51 const beeper = makeBeeper();
2552
2653 // ------------------------------------------------------------------ socket
2754
@@ -35,10 +62,13 @@ function connect() {
3562 };
3663 socket.onmessage = (event) => {
3764 const data = JSON.parse(event.data);
38 if (data.t === "s") {
39 snap = data;
40 paintHud(data);
41 }
65 if (data.t !== "s") return;
66 const first = snap === null;
67 snap = data;
68 if (first && mode === "fp") yaw = data.me.angle;
69 gunSnapshot(gun, data);
70 paintHud(data);
71 maybeHelp(data);
4272 };
4373 }
4474
@@ -48,8 +78,30 @@ const send = (message) => {
4878
4979 // ------------------------------------------------------------------- input
5080
81 function setMode(next) {
82 mode = next;
83 $("stage").dataset.mode = next;
84 if (next !== "fp" && document.pointerLockElement === view) document.exitPointerLock();
85 if (snap) yaw = snap.me.angle;
86 }
87
5188 addEventListener("keydown", (event) => {
5289 if (event.target instanceof HTMLInputElement) return;
90 if (event.code === "Tab") {
91 mapOpen = true;
92 event.preventDefault();
93 return;
94 }
95 if (event.code === "KeyH" || event.key === "?") {
96 toggleHelp();
97 event.preventDefault();
98 return;
99 }
100 if (event.code === "KeyV") {
101 setMode(mode === "fp" ? "top" : "fp");
102 event.preventDefault();
103 return;
104 }
53105 if (event.code === "KeyB") {
54106 toggleBuy();
55107 event.preventDefault();
@@ -61,30 +113,69 @@ addEventListener("keydown", (event) => {
61113 return;
62114 }
63115 keys.add(event.code);
64 if (["KeyW", "KeyA", "KeyS", "KeyD", "Space", "KeyF", "KeyR"].includes(event.code)) event.preventDefault();
116 if (["KeyW", "KeyA", "KeyS", "KeyD", "Space", "KeyF", "KeyR", "ArrowLeft", "ArrowRight"].includes(event.code))
117 event.preventDefault();
118 });
119 addEventListener("keyup", (event) => {
120 if (event.code === "Tab") mapOpen = false;
121 keys.delete(event.code);
122 });
123 addEventListener("blur", () => {
124 keys.clear();
125 mapOpen = false;
126 firing = false;
65127 });
66 addEventListener("keyup", (event) => keys.delete(event.code));
67 addEventListener("blur", () => keys.clear());
68128
69 view.addEventListener("pointermove", (event) => {
70 if (event.pointerType === "touch") return;
129 // Aiming is pointer lock: click the canvas to capture the mouse, move to
130 // look, Escape gives it back. The angle sent is the same angle as ever.
131 view.addEventListener("click", () => {
132 beeper.wake();
133 if (mode === "fp" && !locked && view.requestPointerLock) view.requestPointerLock();
134 });
135 document.addEventListener("pointerlockchange", () => {
136 locked = document.pointerLockElement === view;
137 if (!locked) firing = false;
138 $("stage").dataset.locked = locked ? "1" : "";
139 });
140 addEventListener("mousemove", (event) => {
141 if (locked) yaw += event.movementX * LOOK;
142 });
143 addEventListener("mousedown", (event) => {
144 if (locked && event.button === 0) firing = true;
145 });
146 addEventListener("mouseup", (event) => {
147 if (locked && event.button === 0) firing = false;
148 });
149
150 // The top-down view keeps the pointer aim it always had: it is the phone
151 // control scheme, and the fallback when the first-person view is not wanted.
152 const canvasPoint = (event) => {
71153 const box = view.getBoundingClientRect();
72 pointer = { x: ((event.clientX - box.left) / box.width) * view.width, y: ((event.clientY - box.top) / box.height) * view.height };
154 return {
155 x: ((event.clientX - box.left) / box.width) * view.width,
156 y: ((event.clientY - box.top) / box.height) * view.height,
157 };
158 };
159 view.addEventListener("pointermove", (event) => {
160 if (mode !== "top" || event.pointerType === "touch") return;
161 pointer = canvasPoint(event);
73162 });
74163 view.addEventListener("pointerdown", (event) => {
164 beeper.wake();
165 if (mode !== "top") return;
75166 view.setPointerCapture(event.pointerId);
76167 const box = view.getBoundingClientRect();
77 const x = event.clientX - box.left;
78168 if (event.pointerType === "touch") {
79 if (x < box.width / 2) stick = { id: event.pointerId, ox: event.clientX, oy: event.clientY, mx: 0, my: 0 };
169 if (event.clientX - box.left < box.width / 2)
170 stick = { id: event.pointerId, ox: event.clientX, oy: event.clientY, mx: 0, my: 0 };
80171 else {
81 aimTouch = { id: event.pointerId, x: ((event.clientX - box.left) / box.width) * view.width, y: ((event.clientY - box.top) / box.height) * view.height };
172 aimTouch = { id: event.pointerId, ...canvasPoint(event) };
82173 firing = true;
83174 }
84175 event.preventDefault();
85176 return;
86177 }
87 pointer = { x: ((event.clientX - box.left) / box.width) * view.width, y: ((event.clientY - box.top) / box.height) * view.height };
178 pointer = canvasPoint(event);
88179 firing = true;
89180 });
90181 view.addEventListener("pointermove", (event) => {
@@ -96,11 +187,7 @@ view.addEventListener("pointermove", (event) => {
96187 stick.mx = (dx / len) * reach;
97188 stick.my = (dy / len) * reach;
98189 }
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 }
190 if (aimTouch && event.pointerId === aimTouch.id) Object.assign(aimTouch, canvasPoint(event));
104191 });
105192 const endPointer = (event) => {
106193 if (stick && event.pointerId === stick.id) stick = null;
@@ -113,34 +200,37 @@ view.addEventListener("pointerup", endPointer);
113200 view.addEventListener("pointercancel", endPointer);
114201 view.addEventListener("contextmenu", (event) => event.preventDefault());
115202
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.
203 // Twenty times a second the server hears what this person is doing. In the
204 // first-person view the keys are read against the way you are facing; in the
205 // top-down view they are the compass, the way they always were.
129206 setInterval(() => {
130207 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;
208 let ax = 0;
209 let ay = 0;
210 if (keys.has("KeyW")) ay -= 1;
211 if (keys.has("KeyS")) ay += 1;
212 if (keys.has("KeyA")) ax -= 1;
213 if (keys.has("KeyD")) ax += 1;
137214 if (stick) {
138 mx = stick.mx;
139 my = stick.my;
215 ax = stick.mx;
216 ay = stick.my;
217 }
218 let mx = ax;
219 let my = ay;
220 let angle;
221 if (mode === "fp") {
222 if (keys.has("ArrowLeft")) yaw -= 0.045;
223 if (keys.has("ArrowRight")) yaw += 0.045;
224 angle = yaw;
225 moveVector(ax, ay, yaw, walk);
226 mx = walk.x;
227 my = walk.y;
228 } else {
229 const aim = aimTouch || pointer;
230 const me = worldToScreen(snap.me);
231 angle = aim ? Math.atan2(aim.y - me.y, aim.x - me.x) : snap.me.angle;
232 yaw = angle;
140233 }
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;
144234 send({
145235 t: "i",
146236 mx,
@@ -168,168 +258,69 @@ const worldToScreen = (p) => ({ x: p.x * scale(), y: p.y * scale() });
168258 function smooth(id, x, y, angle) {
169259 const now = shown.get(id);
170260 if (!now) {
171 shown.set(id, { x, y, angle });
172 return { x, y, angle };
261 const fresh = { x, y, angle };
262 shown.set(id, fresh);
263 return fresh;
173264 }
174265 now.x += (x - now.x) * 0.35;
175266 now.y += (y - now.y) * 0.35;
176 let turn = ((angle - now.angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI;
267 const turn = ((angle - now.angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI;
177268 now.angle += turn * 0.4;
178269 return now;
179270 }
180271
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 }
272 const look = (p) => smooth(p.id, p.x, p.y, p.angle);
196273
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() {
274 function frame(now) {
290275 requestAnimationFrame(frame);
276 const dt = Math.min(0.1, (now - last) / 1000);
277 last = now;
291278 if (!map) return;
292 const s = scale();
279 gunTick(gun, dt);
293280 ctx.clearRect(0, 0, view.width, view.height);
294 drawMap(s);
295 if (!snap) return;
296281
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();
282 if (!snap) {
283 drawTopdown(ctx, map, null, look, { x: 0, y: 0, w: view.width, h: view.height });
284 return;
302285 }
303 for (const zone of snap.zones) if (zone.k !== "smoke") drawZone(zone, s);
286 beeper.update(snap.spike.planted, snap.spike.fuse, now / 1000);
287 const eye = look(snap.me);
288 const facing = mode === "fp" ? yaw : eye.angle;
304289
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 }
290 if (mode === "fp" && !mapOpen) {
291 drawScene(ctx, scene, { solid, tile: map.tile }, { x: eye.x, y: eye.y, angle: facing }, snap, {
292 width: view.width,
293 height: view.height,
294 look,
295 });
296 drawGun(ctx, gun, view.width, view.height, snap);
297 drawMinimap();
298 } else {
299 drawTopdown(ctx, map, snap, look, { x: 0, y: 0, w: view.width, h: view.height });
314300 }
315301
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
329302 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");
303 else if (snap.spike.planted && snap.spike.defuse > 0 && snap.me.side === "def")
304 bar(snap.spike.defuse / 7, "#4fd6a0", "defusing");
331305 $("flash").style.opacity = Math.min(0.95, snap.me.blind * 0.8);
332306 $("dead").style.display = snap.me.alive ? "none" : "grid";
307 $("aimhint").style.display = mode === "fp" && !locked && !COARSE ? "grid" : "none";
308 }
309
310 /** The same top-down drawing, small, in the corner, with you as an arrow. */
311 function drawMinimap() {
312 const w = Math.min(view.width * 0.3, 260 * (view.width / 900));
313 const h = (w * map.rows.length) / map.rows[0].length;
314 const pad = 8;
315 ctx.save();
316 ctx.globalAlpha = 0.85;
317 ctx.fillStyle = "#0b0d11";
318 ctx.fillRect(pad - 2, pad - 2, w + 4, h + 4);
319 drawTopdown(ctx, map, snap, look, { x: pad, y: pad, w, h }, { labels: false });
320 ctx.restore();
321 ctx.strokeStyle = "rgba(255,255,255,0.18)";
322 ctx.lineWidth = 1;
323 ctx.strokeRect(pad - 2, pad - 2, w + 4, h + 4);
333324 }
334325
335326 function bar(fraction, colour, label) {
@@ -341,7 +332,7 @@ function bar(fraction, colour, label) {
341332 ctx.fillStyle = colour;
342333 ctx.fillRect(x, y, w * Math.min(1, fraction), 16);
343334 ctx.fillStyle = "#fff";
344 ctx.font = "12px system-ui";
335 ctx.font = "12px system-ui, sans-serif";
345336 ctx.textAlign = "center";
346337 ctx.fillText(label, view.width / 2, y + 12);
347338 ctx.textAlign = "left";
@@ -349,6 +340,50 @@ function bar(fraction, colour, label) {
349340
350341 // --------------------------------------------------------------------- hud
351342
343 // The buy opens itself every buy phase; closing it is remembered until the
344 // next one, so nobody has to fight the panel to watch the round start.
345 function toggleBuy() {
346 const open = $("buy").classList.toggle("on");
347 $("buy").dataset.closed = open ? "" : "1";
348 }
349 $("buytoggle").addEventListener("click", toggleBuy);
350 $("viewtoggle").addEventListener("click", () => setMode(mode === "fp" ? "top" : "fp"));
351 $("helptoggle").addEventListener("click", () => toggleHelp());
352 $("helpclose").addEventListener("click", () => toggleHelp(false));
353 for (const button of document.querySelectorAll("[data-buy]"))
354 button.addEventListener("click", () => send({ t: "buy", what: button.dataset.buy }));
355 for (const button of document.querySelectorAll("[data-agent]"))
356 button.addEventListener("click", () => send({ t: "agent", id: button.dataset.agent }));
357
358 function toggleHelp(force) {
359 const open = force === undefined ? $("help").style.display !== "grid" : force;
360 $("help").style.display = open ? "grid" : "none";
361 if (!open) {
362 helpShown = true;
363 try {
364 localStorage.setItem(HELP_SEEN, "1");
365 } catch {
366 // A browser that refuses storage still gets the card, every visit.
367 }
368 }
369 }
370
371 /** The first-run card, once per browser, and never over a round in progress. */
372 function maybeHelp(s) {
373 if (helpShown) return;
374 try {
375 if (localStorage.getItem(HELP_SEEN) === "1") {
376 helpShown = true;
377 return;
378 }
379 } catch {
380 /* no storage: show it and move on */
381 }
382 if (s.phase === "live" && s.me.alive) return;
383 helpShown = true;
384 $("help").style.display = "grid";
385 }
386
352387 const KITS = {};
353388
354389 function paintHud(s) {
@@ -364,28 +399,20 @@ function paintHud(s) {
364399 $("buy").classList.toggle("on", s.phase === "buy" && $("buy").dataset.closed !== "1");
365400 if (s.phase !== "buy") $("buy").dataset.closed = "";
366401
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 }
402 const text = choosePrompt(s, {
403 onSite: onSite(s.me),
404 nearSpike: s.spike.x !== null && Math.hypot(s.spike.x - s.me.x, s.spike.y - s.me.y) < REACH,
405 });
406 if ($("prompt").textContent !== text) $("prompt").textContent = text;
407
408 paintKit(s);
409 paintBuy(s);
383410
384411 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 = "";
412 const lines = s.events.slice(0, 3).join("\n");
413 if (log.dataset.text !== lines) {
414 log.dataset.text = lines;
415 log.replaceChildren();
389416 for (const line of s.events.slice(0, 3)) {
390417 const div = document.createElement("div");
391418 div.textContent = line;
@@ -399,7 +426,7 @@ function paintHud(s) {
399426 const key = rows.map((p) => `${p.name}${p.kills}${p.deaths}${p.alive}${p.credits}`).join();
400427 if (body.dataset.key === key) continue;
401428 body.dataset.key = key;
402 body.innerHTML = "";
429 body.replaceChildren();
403430 for (const player of rows) {
404431 const tr = document.createElement("tr");
405432 const cells = [
@@ -419,11 +446,103 @@ function paintHud(s) {
419446 }
420447 }
421448
422 fetch("/map.json")
423 .then((r) => r.json())
424 .then((data) => {
425 map = data;
449 /** Is this player standing where the spike arms? The grid says so. */
450 function onSite(me) {
451 if (!map) return false;
452 const row = map.rows[Math.floor(me.y / map.tile)];
453 const cell = row ? row[Math.floor(me.x / map.tile)] : "";
454 return cell === "A" || cell === "B";
455 }
456
457 const kitOf = (id) => (agents ? agents.find((a) => a.id === id) : null);
458
459 /** The four ability buttons, each saying what it does and what it has left. */
460 function paintKit(s) {
461 const kit = $("kit");
462 const agent = kitOf(s.me.agent);
463 const shape =
464 ["c", "q", "e", "x"].map((slot) => `${slot}${s.me.charges[slot]}${Math.ceil(s.me.cooldown[slot])}`).join("|") +
465 s.me.agent +
466 s.me.ult +
467 (agent ? "k" : "");
468 if (KITS.shape === shape) return;
469 KITS.shape = shape;
470 kit.replaceChildren();
471 for (const slot of ["c", "q", "e", "x"]) {
472 const ability = agent ? agent.abilities[slot] : null;
473 const button = document.createElement("button");
474 const ready = slot === "x" ? s.me.ult >= s.me.ultNeeds : s.me.charges[slot] > 0 && s.me.cooldown[slot] <= 0;
475 button.disabled = !ready;
476 button.type = "button";
477 const count =
478 slot === "x"
479 ? `${s.me.ult}/${s.me.ultNeeds}`
480 : s.me.cooldown[slot] > 0
481 ? `${Math.ceil(s.me.cooldown[slot])}s`
482 : s.me.charges[slot];
483 const name = document.createElement("span");
484 name.textContent = `${slot.toUpperCase()}${ability ? " " + ability.name : ""}`;
485 const tally = document.createElement("b");
486 tally.textContent = String(count);
487 button.append(name, tally);
488 if (ability) button.title = `${ability.name} — ${ability.text}`;
489 button.addEventListener("click", () => send({ t: "i", c: slot }));
490 kit.append(button);
491 }
492 }
493
494 /** Prices on every button, greyed when you cannot afford it. */
495 function paintBuy(s) {
496 const agent = kitOf(s.me.agent);
497 for (const button of document.querySelectorAll("[data-buy]")) {
498 const what = button.dataset.buy;
499 let cost = Number(button.dataset.cost ?? 0);
500 if ((what === "c" || what === "q") && agent) {
501 const ability = agent.abilities[what];
502 cost = ability.cost;
503 button.replaceChildren();
504 const name = document.createElement("span");
505 name.textContent = `${what.toUpperCase()} ${ability.name}`;
506 const price = document.createElement("b");
507 price.textContent = `${ability.cost} · ${s.me.charges[what]}/${ability.charges}`;
508 button.append(name, price);
509 button.title = `${ability.text} (${ability.charges} charge${ability.charges === 1 ? "" : "s"} a round)`;
510 }
511 button.disabled = cost > s.me.credits;
512 }
513 for (const button of document.querySelectorAll("[data-agent]")) {
514 const picked = button.dataset.agent === s.me.agent;
515 button.style.outline = picked ? "2px solid #6fcf9f" : "";
516 }
517 }
518
519 // ------------------------------------------------------------------- start
520
521 Promise.all([fetch("/map.json").then((r) => r.json()), fetch("/agents.json").then((r) => r.json())])
522 .then(([mapData, kit]) => {
523 map = mapData;
524 agents = kit.agents;
525 const rows = map.rows;
526 const cols = rows[0].length;
527 solid = (col, row) =>
528 col < 0 || row < 0 || row >= rows.length || col >= cols || rows[row][col] === "#";
529 $("stage").dataset.mode = mode;
530 fillHelp();
426531 resize();
427532 connect();
428 });
533 })
534 .catch(() => ($("conn").textContent = "offline"));
535
536 /** The card says the round, the sides, the keys and the kit, in that order. */
537 function fillHelp() {
538 const list = $("helpagents");
539 if (!list || !agents) return;
540 list.replaceChildren();
541 for (const agent of agents) {
542 const li = document.createElement("li");
543 li.textContent = `${agent.name} (${agent.role}) — ${agent.blurb}`;
544 list.append(li);
545 }
546 }
547
429548 requestAnimationFrame(frame);
↑↓ navigate ↵ open esc close

Keyboard shortcuts