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

forked from trevato/cs · proposes

mini map. especially on procedural maps i need to know where im going #179

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

vesper/cs-engine:vesper/176-mini-map-especially-on-procedural-maps-i → main 1 commit fork

  • Merged. vesper/176-mini-map-especially-on-procedural-maps-i was merged into main as e3a3b12.

Review

Sign in to review this change.
11 files changed +651 −121
README.md +26 −3
@@ -114,6 +114,16 @@ phone, where there is no keyboard and no pointer to lock. A browser that will no
114114 put an element full screen — an iPhone will not — never shows that button rather
115115 than showing one that does nothing.
116116
117 The radar sits in the top corner: the level from above, north up and never
118 turning, with the rooms in grey, the two bombsites lettered A and B, your side
119 in your side's colour and a white wedge for where you are and which way you are
120 looking. An enemy appears on it while somebody alive on your side can see them
121 and goes the moment nobody can — there is no memory of where one was a second
122 ago. The bomb is on it too: yellow in the hands of whoever is carrying it, on
123 the floor for the terrorists to come back for, and blinking red where it was
124 planted, for both sides. It is the only thing on the screen that says where you
125 are on a map generated for that one match, which is why #176 built it.
126
117127 Killed, you watch the rest of the round out of a teammate's eyes: fire moves
118128 to the next player on your side and aim moves back one, the camera is *placed*
119129 on whoever you picked — asking for someone else is a cut, never a flight across
@@ -348,9 +358,16 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and
348358 `binding`, `scene`, `hud` (the crosshair, the health, the ammo, the round
349359 clock, the kill feed and the scoreboard — every one of them drawn through the
350360 engine's `overlay` since #18, so what is left in the file is this game's
351 layout, its team colours, which phases have a clock and its words, and the
361 layout, which phases have a clock and its words, and the
352362 scale, the scrim, the fonts, the fade, the bar, the columns and the `m:ss` are
353 the same code the range's panel is drawn with), `spectate` (whose eyes a frame
363 the same code the range's panel is drawn with),
364 `minimap` (the radar in the corner: the level from above, merged into a few
365 dozen rectangles once per map by the engine's greedy meshing and replayed
366 every frame, north up, with your wedge, your side, the enemies your side can
367 see right now and the bomb — it knows no alphabet, so floor is whatever the
368 level calls passable and a crate is a dark block in a room),
369 `palette` (what colour a side is, in the one place both surfaces read it),
370 `spectate` (whose eyes a frame
354371 is drawn from once you are dead, and the camera that eases onto them),
355372 `audio`, `main`. Everything in here is headless and
356373 tested: the WebGL2 is the engine's now, and this game has no renderer of its
@@ -405,7 +422,13 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and
405422 clock that stops between rounds and reddens once the bomb is down, the
406423 crosshair that opens as far as the gun is inaccurate, the plant bar, the kill
407424 feed fading out over eight seconds, the scoreboard's order, and the whole
408 readout fitting a 360px phone and a 1920px desk alike.
425 readout fitting a 360px phone and a 1920px desk alike. That recorder is
426 `paint.test-helper.ts` now, because `minimap.test.ts` reads the radar the same
427 way: on a map the colour *is* the meaning, so the brush is half the assertion
428 — the north-west cell of the grid lands in the north-west corner of the
429 square, the wedge points where you look, an enemy is a red dot exactly while
430 somebody alive on your side can see them, and the planted bomb blinks where
431 it lies.
409432 - `src/range/` — the second game, at `/range`, and the proof that the engine is a
410433 package: a module in here may import the engine through its door, `src/pages/shell.ts`
411434 and its own siblings, and nothing else. **It is a game with no renderer under
src/client/hud.test.ts +7 −43
@@ -10,18 +10,18 @@
1010 * deletes rather than uses.
1111 *
1212 * The `Paint` it draws onto is the HUD's — the engine's surface with the styles
13 * narrowed to the strings this game writes into them, which is why a recorder
14 * declared as it hands a `string` back when the brush is read. The recorder is
15 * this file's own because it writes down the brush as well as the call: the
16 * colour a line was drawn in is half of what is pinned here, and neither of the
17 * two recorders already in the repository (`stage.test-helper.ts`,
18 * `overlay.test.ts`) keeps it.
13 * narrowed to the strings this game writes into them, which is why the recorder
14 * hands a `string` back when the brush is read. The recorder writes down the
15 * brush as well as the call, because the colour a line was drawn in is half of
16 * what is pinned here; it lived in this file until the radar wanted the same
17 * one, and it is `paint.test-helper.ts` now.
1918 *
2019 * No canvas, no document, no browser.
2120 */
2221
2322 import { expect, test } from "bun:test";
24 import { drawHud, type Paint } from "./hud.ts";
23 import { drawHud } from "./hud.ts";
24 import { recorder, type Call, type Recorder } from "./paint.test-helper.ts";
2525 import { DE_FORGE } from "./map.ts";
2626 import { SHORT } from "./match.ts";
2727 import { inaccuracy, WEAPONS } from "./weapons.ts";
@@ -38,42 +38,6 @@ function only(effect: Effect): ReturnType<typeof createEffects<Effect>> {
3838 return effects;
3939 }
4040
41 // --- a paint that remembers ------------------------------------------------
42
43 /** One call the HUD made, and the state of the brush that made it. */
44 type Call = {
45 op: string;
46 args: readonly unknown[];
47 fill: string;
48 stroke: string;
49 font: string;
50 align: string;
51 alpha: number;
52 };
53
54 type Recorder = Paint & { calls: Call[] };
55
56 function recorder(): Recorder {
57 const calls: Call[] = [];
58 const note = (op: string) => (...args: unknown[]): void => void calls.push({
59 op,
60 args,
61 fill: paint.fillStyle,
62 stroke: paint.strokeStyle,
63 font: paint.font,
64 align: paint.textAlign,
65 alpha: paint.globalAlpha,
66 });
67 const paint: Recorder = {
68 calls,
69 fillStyle: "", strokeStyle: "", lineWidth: 0, font: "", textAlign: "", textBaseline: "", globalAlpha: 1,
70 clearRect: note("clearRect"), fillRect: note("fillRect"), fillText: note("fillText"),
71 beginPath: note("beginPath"), moveTo: note("moveTo"), lineTo: note("lineTo"), rect: note("rect"),
72 arc: note("arc"), fill: note("fill"), stroke: note("stroke"),
73 };
74 return paint;
75 }
76
7741 // --- the world this suite draws --------------------------------------------
7842
7943 /**
src/client/hud.ts +26 −16
@@ -1,7 +1,12 @@
11 /**
22 * The HUD, drawn on a 2D canvas over the scene: crosshair, health, ammo, the
3 * round clock, the kill feed and the scoreboard. It reads the world and draws;
4 * it never changes anything.
3 * round clock, the kill feed, the scoreboard and the radar. It reads the world
4 * and draws; it never changes anything.
5 *
6 * The radar is `src/client/minimap.ts` — the level from above is a picture of
7 * its own and everything about drawing one is in there. What stays here is what
8 * stays here for every other thing on the screen: where the square sits and how
9 * big it is, which is the one question this file answers.
510 *
611 * What it draws onto is a `Paint` — the seventeen members of a 2D context it
712 * actually uses, and not a whole `CanvasRenderingContext2D`. A browser's
@@ -14,11 +19,14 @@
1419 * crosshair, a progress bar, a row of right-aligned columns and the `m:ss`
1520 * clock — the same tools `src/range/readout.ts` draws its panel with. What is
1621 * left in this file is Counter-Strike's and nothing else: where a thing sits,
17 * what colour a team is, which phase has a clock, and the words.
22 * which phase has a clock, and the words. What colour a side is drawn in is
23 * `src/client/palette.ts`, because the radar draws the same ten players.
1824 */
1925
2026 import { clamp, effectAt, overlay, type Overlay, type Paint as Surface } from "../engine/index.ts";
27 import { drawMinimap } from "./minimap.ts";
2128 import { CS_MOVE } from "./movement.ts";
29 import { BLUE, CROSSHAIR, CT_COLOR, GREY, RED, T_COLOR, teamColor, WHITE } from "./palette.ts";
2230 import { inaccuracy, WEAPONS } from "./weapons.ts";
2331 import { currentWeapon, useProgress, type Player, type World } from "./world.ts";
2432
@@ -36,13 +44,12 @@ export type Paint = Surface & {
3644 textBaseline: string;
3745 };
3846
39 const T_COLOR = "#e2b04a";
40 const CT_COLOR = "#6fa8e8";
41 const WHITE = "#f3f5f8";
42 const RED = "#ff6b5b";
43 const BLUE = "#9fd0ff";
44 const GREY = "#c8ccd4";
45 const CROSSHAIR = "rgba(120, 255, 140, 0.9)";
47 /**
48 * The radar's square, before the scale: 180 of them on a 1280-wide canvas. It
49 * is also capped at a third of the shorter side, so a short landscape phone
50 * does not get a map with the clock sitting on it.
51 */
52 const RADAR = 180;
4653
4754 /** What a dead player is told, in the two cases there are. */
4855 const DEAD = "You are dead — watching the round out";
@@ -158,10 +165,13 @@ export function drawHud(
158165 pad, height - pad - Math.round(30 * scale),
159166 { align: "left", font: small, fill: BLUE },
160167 );
161 ui.text(`$${view.money}`, pad, pad + Math.round(18 * scale),
162 { align: "left", font: mid, fill: "#7ddc8a" });
168 // --- the radar, top left, and the money under it rather than over it
169 const radar = Math.min(Math.round(RADAR * scale), Math.round(Math.min(width, height) * 0.3));
170 drawMinimap(ctx, ui, world, view, pad, pad, radar);
171 const moneyY = pad + radar + Math.round(22 * scale);
172 ui.text(`$${view.money}`, pad, moneyY, { align: "left", font: mid, fill: "#7ddc8a" });
163173 if (view.team === "ct" && view.defuseKit)
164 ui.text("defuse kit", pad, pad + Math.round(38 * scale), { align: "left", font: small, fill: BLUE });
174 ui.text("defuse kit", pad, moneyY + Math.round(20 * scale), { align: "left", font: small, fill: BLUE });
165175
166176 // --- ammo and weapon
167177 const ammo = view.ammo[weapon.id];
@@ -190,7 +200,7 @@ export function drawHud(
190200 if (age > 8) return;
191201 const text = `${entry.killer} ${WEAPONS[entry.weapon].name}${entry.headshot ? " ⌖" : ""} ${entry.victim}`;
192202 ui.text(text, width - pad, pad + Math.round((20 + i * 22) * scale), {
193 align: "right", font: small, fill: entry.killerTeam === "t" ? T_COLOR : CT_COLOR, alpha: 8 - age,
203 align: "right", font: small, fill: teamColor(entry.killerTeam), alpha: 8 - age,
194204 });
195205 });
196206
@@ -221,7 +231,7 @@ export function drawHud(
221231 const y = height - pad - Math.round(64 * scale);
222232 ui.panel(width / 2 - w / 2, y - h + Math.round(8 * scale), w, h);
223233 ui.text(`${WATCHING} ${view.name}`, width / 2, y - Math.round(14 * scale),
224 { align: "center", font: mid, fill: view.team === "t" ? T_COLOR : CT_COLOR });
234 { align: "center", font: mid, fill: teamColor(view.team) });
225235 ui.text(SWITCH, width / 2, y, { align: "center", font: small, fill: GREY });
226236 }
227237 }
@@ -248,7 +258,7 @@ function drawScoreboard(ctx: Paint, ui: Overlay, world: World): void {
248258 // the fade is set around them rather than passed to each line.
249259 const dim = p.alive ? 1 : 0.45;
250260 ui.text(`${p.team === "t" ? "T" : "CT"} ${p.name}${p.bot ? "" : " (you)"}`, x + 16, ty, {
251 align: "left", fill: p === world.me ? "#ffffff" : p.team === "t" ? T_COLOR : CT_COLOR, alpha: dim,
261 align: "left", fill: p === world.me ? "#ffffff" : teamColor(p.team), alpha: dim,
252262 });
253263 ctx.globalAlpha = dim;
254264 ui.columns(NOTHING, [
src/client/minimap.test.ts added +258
@@ -0,0 +1,258 @@
1 /**
2 * What a player reads off the radar, and nothing about how `minimap.ts` is
3 * arranged to draw it: that the map is the level and fits its square, that
4 * north is up, that the wedge points where you look, that an enemy is on it
5 * exactly while your side can see them, and that the bomb is where the bomb is.
6 *
7 * The one thing pinned to the pixel is the projection — a map whose corners are
8 * not the level's corners is a map that lies about where you are — and it is
9 * pinned as a property (the north-west cell lands in the north-west corner),
10 * never as a number somebody would have to re-derive after a retune.
11 *
12 * It draws onto `paint.test-helper.ts`'s recorder, which keeps the brush as
13 * well as the call, because on a radar the colour *is* the meaning: yellow is
14 * your side, red is theirs, and a rectangle the colour of the floor is a room.
15 *
16 * No canvas, no document, no browser.
17 */
18
19 import { expect, test } from "bun:test";
20 import { overlay } from "../engine/index.ts";
21 import { generateLevel } from "./generate.ts";
22 import { drawHud } from "./hud.ts";
23 import { worldOf, type Level } from "./level.ts";
24 import { DE_FORGE } from "./map.ts";
25 import { SHORT } from "./match.ts";
26 import { drawMinimap, planOf } from "./minimap.ts";
27 import { recorder, type Call, type Recorder } from "./paint.test-helper.ts";
28 import { canSee, createWorld, type Player, type World } from "./world.ts";
29
30 /** The square the radar is asked for, somewhere that is not the origin. */
31 const X = 20;
32 const Y = 30;
33 const SIDE = 200;
34
35 /** The colours this file reads back. They are `minimap.ts`'s, written out once. */
36 const FLOOR = "rgba(146, 156, 174, 0.5)";
37 const BACKDROP = "rgba(8, 10, 14, 0.62)";
38 const T_COLOR = "#e2b04a";
39 const CT_COLOR = "#6fa8e8";
40 const ENEMY = "#ff6b5b";
41 const BOMB_ON = "#ff4a3d";
42 const BOMB_OFF = "rgba(255, 74, 61, 0.35)";
43 const BOMB_LOOSE = "#ffd166";
44
45 /** A round on de_forge with `size` players a side, laid out by `startRound`. */
46 const round = (team: "t" | "ct", size = 5, level: Level = DE_FORGE): World =>
47 createWorld(level, SHORT, "you", team, size, 64);
48
49 function draw(world: World, view: Player = world.me): Recorder {
50 const paint = recorder();
51 drawMinimap(paint, overlay(paint, 1280, 720), world, view, X, Y, SIDE);
52 return paint;
53 }
54
55 const ops = (paint: Recorder, op: string): Call[] => paint.calls.filter((c) => c.op === op);
56 /** Every dot on the map: a player, a halo under one, and nothing else is round. */
57 const dots = (paint: Recorder): Call[] => ops(paint, "arc");
58 const inColor = (calls: Call[], color: string): Call[] => calls.filter((c) => c.fill === color);
59 const at = (call: Call): [number, number] => [call.args[0] as number, call.args[1] as number];
60
61 /** Everyone on a side, in the order the world holds them. */
62 const side = (world: World, team: "t" | "ct"): Player[] => world.players.filter((p) => p.team === team);
63
64 test("the map is the level, drawn inside the square it was given and nowhere else", () => {
65 const paint = draw(round("ct"));
66
67 // The backdrop is the square, to the pixel: what is under it is the scene.
68 const backdrop = inColor(ops(paint, "fillRect"), BACKDROP);
69 expect(backdrop.length).toBe(1);
70 expect(backdrop[0]?.args).toEqual([X, Y, SIDE, SIDE]);
71
72 // Every room on it, and every mark, is inside that square.
73 for (const rect of ops(paint, "fillRect")) {
74 const [x, y] = at(rect);
75 expect(x).toBeGreaterThanOrEqual(X);
76 expect(y).toBeGreaterThanOrEqual(Y);
77 expect(x + (rect.args[2] as number)).toBeLessThanOrEqual(X + SIDE);
78 expect(y + (rect.args[3] as number)).toBeLessThanOrEqual(Y + SIDE);
79 }
80 for (const dot of dots(paint)) {
81 const [x, y] = at(dot);
82 expect(x).toBeGreaterThan(X);
83 expect(x).toBeLessThan(X + SIDE);
84 expect(y).toBeGreaterThan(Y);
85 expect(y).toBeLessThan(Y + SIDE);
86 }
87
88 // There are rooms on it, and the two bombsites are lettered.
89 expect(inColor(ops(paint, "fillRect"), FLOOR).length).toBeGreaterThan(10);
90 expect(ops(paint, "fillText").map((c) => c.args[0])).toEqual(["A", "B"]);
91 });
92
93 test("north is up, west is left, and the whole grid fits the square", () => {
94 const world = round("ct", 5);
95 const [mate, far] = side(world, "ct").filter((p) => p !== world.me);
96 const { width, height } = world.level.grid;
97 // The two opposite corner cells of the grid, which is the widest claim the
98 // projection can be asked to keep.
99 (mate as Player).pos = worldOf(world.level, 0, 0);
100 (far as Player).pos = worldOf(world.level, width - 1, height - 1);
101 const marks = inColor(dots(draw(world)), CT_COLOR).map(at);
102
103 const cell = SIDE / width;
104 const northWest = marks.find(([x, y]) => x < X + cell && y < Y + cell);
105 const southEast = marks.find(([x, y]) => x > X + SIDE - cell && y > Y + SIDE - cell);
106 expect(northWest).toBeDefined();
107 expect(southEast).toBeDefined();
108 });
109
110 test("you are a wedge pointing wherever you are looking", () => {
111 const world = round("ct", 1);
112 world.me.pos = [0, 0, 0]; // the middle of the map is the middle of the square
113 const middle: [number, number] = [X + SIDE / 2, Y + SIDE / 2];
114
115 // The wedge is the one path on the radar: its `moveTo` is the tip.
116 const tipAt = (yaw: number): [number, number] => {
117 world.me.yaw = yaw;
118 const path = ops(draw(world), "moveTo");
119 expect(path.length).toBe(1);
120 return at(path[0] as Call);
121 };
122
123 const east = tipAt(0);
124 expect(east[0]).toBeGreaterThan(middle[0]);
125 expect(east[1]).toBeCloseTo(middle[1], 5);
126
127 // Yaw rises north, and on a screen north is the smaller y.
128 const north = tipAt(90);
129 expect(north[1]).toBeLessThan(middle[1]);
130 expect(north[0]).toBeCloseTo(middle[0], 5);
131
132 expect(tipAt(180)[0]).toBeLessThan(middle[0]);
133 expect(tipAt(-90)[1]).toBeGreaterThan(middle[1]);
134
135 // Dead with nobody left to watch, there is no wedge: a corpse faces nowhere.
136 world.me.alive = false;
137 expect(ops(draw(world), "moveTo").length).toBe(0);
138 });
139
140 test("an enemy is on the radar while your side can see them, and off it when nobody can", () => {
141 const world = round("ct", 1);
142 const enemy = side(world, "t")[0] as Player;
143 const level = world.level;
144
145 // Two cells apart down mid, with nothing in between.
146 world.me.pos = worldOf(level, 22, 20);
147 enemy.pos = worldOf(level, 22, 18);
148 expect(canSee(world, world.me, enemy)).toBe(true);
149 expect(inColor(dots(draw(world)), ENEMY).length).toBe(1);
150
151 // The same enemy inside B site, with half the map between them.
152 enemy.pos = worldOf(level, 5, 5);
153 expect(canSee(world, world.me, enemy)).toBe(false);
154 expect(inColor(dots(draw(world)), ENEMY).length).toBe(0);
155
156 // Dead, you see nothing for your side: the sight is a living teammate's.
157 enemy.pos = worldOf(level, 22, 18);
158 world.me.alive = false;
159 expect(inColor(dots(draw(world)), ENEMY).length).toBe(0);
160
161 // And a dead enemy is not a dot, however plainly you can see the body.
162 world.me.alive = true;
163 enemy.alive = false;
164 expect(inColor(dots(draw(world)), ENEMY).length).toBe(0);
165 });
166
167 test("your side is always on it, wherever they are standing", () => {
168 const world = round("ct", 5);
169 const mates = side(world, "ct").filter((p) => p !== world.me);
170 // Scattered over the map, most of them out of sight of everybody.
171 mates.forEach((mate, i) => { mate.pos = worldOf(world.level, 4 + i * 9, 5 + i * 8); });
172 expect(inColor(dots(draw(world)), CT_COLOR).length).toBe(mates.length);
173 // A dead teammate is off it, like a dead enemy.
174 (mates[0] as Player).alive = false;
175 expect(inColor(dots(draw(world)), CT_COLOR).length).toBe(mates.length - 1);
176 });
177
178 test("the bomb: planted, everybody sees it blink; on the floor, only the terrorists", () => {
179 const world = round("ct", 5);
180 world.bomb.state = "planted";
181 world.bomb.pos = world.level.sites.A;
182 world.bomb.site = "A";
183
184 // It blinks twice a second, and both halves of the blink are the same mark.
185 world.time = 0.2;
186 const on = inColor(ops(draw(world), "fillRect"), BOMB_ON);
187 world.time = 0.7;
188 const off = inColor(ops(draw(world), "fillRect"), BOMB_OFF);
189 expect(on.length).toBe(1);
190 expect(off.length).toBe(1);
191 expect(at(on[0] as Call)).toEqual(at(off[0] as Call));
192
193 // A dropped bomb is a terrorist's business: their radar has it, the CTs' does not.
194 world.bomb.state = "dropped";
195 world.bomb.pos = worldOf(world.level, 22, 30);
196 expect(inColor(ops(draw(world), "fillRect"), BOMB_LOOSE).length).toBe(0);
197 const carrier = side(world, "t")[0] as Player;
198 expect(inColor(ops(draw(world, carrier), "fillRect"), BOMB_LOOSE).length).toBe(1);
199 });
200
201 test("whoever is carrying it wears a halo, on either side", () => {
202 const world = round("t", 5);
203 const carrier = world.players.find((p) => p.hasBomb) as Player;
204 expect(carrier).toBeDefined();
205 expect(carrier.team).toBe("t");
206 // `startRound` hands the bomb to a terrorist: this round that is a teammate.
207 const haloes = inColor(dots(draw(world)), BOMB_LOOSE);
208 expect(haloes.length).toBe(1);
209 expect(inColor(dots(draw(world)), T_COLOR).length).toBe(side(world, "t").length - 1);
210
211 // And it moves with them: give it to the player behind the camera instead.
212 carrier.hasBomb = false;
213 world.me.hasBomb = true;
214 const mine = inColor(dots(draw(world)), BOMB_LOOSE);
215 expect(mine.length).toBe(1);
216 expect(at(mine[0] as Call)).not.toEqual(at(haloes[0] as Call));
217 });
218
219 test("a level's rectangles are built once, and they are exactly its floor", () => {
220 const plan = planOf(DE_FORGE);
221 expect(planOf(DE_FORGE)).toBe(plan);
222 expect(plan.span).toBe(DE_FORGE.grid.width * DE_FORGE.grid.cell);
223
224 // Every walkable cell is drawn exactly once: the merged rectangles cover the
225 // floor and nothing that is not floor.
226 const { grid } = DE_FORGE;
227 const area = plan.floor.reduce((a, b) => a + (b.max[0] - b.min[0]) * (b.max[1] - b.min[1]), 0);
228 expect(area).toBe(grid.cells.filter(DE_FORGE.passable).length * grid.cell * grid.cell);
229 // And it is a few dozen rectangles rather than the 1,936 cells they came from.
230 expect(plan.floor.length).toBeLessThan(grid.cells.length / 8);
231
232 // A map nobody wrote is a map like any other: its own plan, its own size.
233 const generated = generateLevel(12345);
234 expect(planOf(generated)).not.toBe(plan);
235 expect(planOf(generated).span).toBe(generated.grid.width * generated.grid.cell);
236 expect(planOf(generated).sites.map((s) => s.letter)).toEqual(["A", "B"]);
237 expect(draw(round("ct", 5, generated)).calls.length).toBeGreaterThan(20);
238 });
239
240 test("the HUD puts it in the top corner and keeps the money out from under it", () => {
241 const world = round("ct", 5);
242 const paint = recorder();
243 drawHud(paint, world, 1280, 720, false, world.me);
244
245 const backdrop = inColor(ops(paint, "fillRect"), BACKDROP)[0] as Call;
246 const [x, y] = at(backdrop);
247 const square = backdrop.args[2] as number;
248 expect(backdrop.args[3]).toBe(square);
249 // Up in the corner, and small enough to leave the round clock alone.
250 expect(x).toBeLessThan(1280 / 4);
251 expect(y).toBeLessThan(720 / 4);
252 expect(square).toBeLessThan(720 / 3);
253
254 // The money used to sit where the map is now, and it does not any more.
255 // `fillText` is (text, x, y): the baseline is the third argument.
256 const money = ops(paint, "fillText").find((c) => String(c.args[0]).startsWith("$")) as Call;
257 expect(money.args[2] as number).toBeGreaterThan(y + square);
258 });
src/client/minimap.ts added +191
@@ -0,0 +1,191 @@
1 /**
2 * The radar: the level seen from above, in a square in the corner of the HUD.
3 * A generated map is a map nobody has ever walked, so this is the only thing on
4 * the screen that says where the rooms are, which way the site you are pushing
5 * lies, and where the bomb is now.
6 *
7 * It reads the level and the world and draws; like the rest of the HUD it
8 * changes nothing. North is up and the square never turns: a map that spins
9 * under you tells you which way you face, which the wedge already does, and
10 * costs you the one thing worth having — a picture of the place that is the
11 * same picture every time you glance at it.
12 *
13 * It knows no alphabet. What is floor is the level's own `passable`, what is a
14 * bombsite is its `siteCells`, and everything else stays backdrop — which is
15 * why a crate shows up as a dark block inside a room without this file ever
16 * having heard of the letter `x`. The rectangles are the engine's greedy
17 * meshing, the same call `cells.ts` builds the geometry with, so a 40×40 map is
18 * a few dozen `fillRect`s a frame rather than sixteen hundred; they depend on
19 * nothing but the level, so they are built once per level and replayed.
20 *
21 * Every mark on it is filled and none of it is stroked. That is not a style: on
22 * this HUD a stroke is the crosshair or a hit marker, and a radar that stroked
23 * a ring would be a third thing answering to the same brush.
24 *
25 * What it will not do is tell you something the round has not: an enemy is on
26 * the radar while somebody alive on your side can see them, and off it the
27 * moment nobody can. There is no memory of where one was a second ago, which is
28 * deliberate — a dot that lingers is a dot that lies.
29 */
30
31 import { mergeSolid, type Box, type Overlay, type Paint, type Vec3 } from "../engine/index.ts";
32 import type { Level } from "./level.ts";
33 import { RED, teamColor, WHITE } from "./palette.ts";
34 import { canSee, type Player, type World } from "./world.ts";
35
36 /** The dark the map is drawn on: enough to read a floor against a bright scene. */
37 const BACKDROP = "rgba(8, 10, 14, 0.62)";
38 const FLOOR = "rgba(146, 156, 174, 0.5)";
39 const SITE = "rgba(226, 176, 74, 0.42)";
40 const LABEL = "rgba(255, 255, 255, 0.72)";
41 /** The bomb, planted: it blinks between these two twice a second, like the beep. */
42 const BOMB_ON = "#ff4a3d";
43 const BOMB_OFF = "rgba(255, 74, 61, 0.35)";
44 /** The bomb on the floor, and in a terrorist's hands: the same yellow both times. */
45 const BOMB_LOOSE = "#ffd166";
46
47 const TAU = Math.PI * 2;
48
49 /**
50 * Where a level's rectangles are, in world units. Everything in here is a
51 * function of the level and of nothing else — not of the size of the square, not
52 * of the frame — so it is built on the first frame of a match and replayed on
53 * every frame after it.
54 */
55 export type Plan = {
56 /** World units along the longer side: what the square is fitted to. */
57 readonly span: number;
58 /** Everything a player can stand on, merged into as few rectangles as the shape allows. */
59 readonly floor: readonly Box[];
60 /** The two bombsites, drawn over the floor and lettered. */
61 readonly sites: readonly { readonly letter: "A" | "B"; readonly boxes: readonly Box[] }[];
62 };
63
64 /**
65 * The plan of this level, built once. A map is played for a whole match and a
66 * generated one is a new value every match, so the cache is weak: a level the
67 * game has finished with takes its rectangles with it.
68 */
69 const plans = new WeakMap<Level, Plan>();
70
71 export function planOf(level: Level): Plan {
72 const found = plans.get(level);
73 if (found !== undefined) return found;
74 const { grid } = level;
75 const plan: Plan = {
76 span: Math.max(grid.width, grid.height) * grid.cell,
77 floor: mergeSolid(grid, level.passable, 0, 1, 0),
78 sites: (["A", "B"] as const).map((letter) => ({
79 letter,
80 boxes: mergeSolid(grid, (cell) => cell === level.siteCells[letter], 0, 1, 0),
81 })),
82 };
83 plans.set(level, plan);
84 return plan;
85 }
86
87 /**
88 * Draw the radar into the square at (`x`, `y`) of side `side`. `view` is whose
89 * eyes this frame is drawn from — you, or the player you are watching once you
90 * are dead — which decides which side is yours and who counts as an enemy.
91 *
92 * Where the square sits is the HUD's business, like every other position on the
93 * screen; what goes in it is this file's.
94 */
95 export function drawMinimap(
96 ctx: Paint,
97 ui: Overlay,
98 world: World,
99 view: Player,
100 x: number,
101 y: number,
102 side: number,
103 ): void {
104 const level = world.level;
105 const plan = planOf(level);
106 // The map is centred on the world's origin and so is the square.
107 const k = side / plan.span;
108 const midX = x + side / 2;
109 const midY = y + side / 2;
110 const atX = (wx: number): number => midX + wx * k;
111 // Rows run south and the world runs north, so the map is drawn upside down on
112 // purpose: north is up.
113 const atY = (wy: number): number => midY - wy * k;
114
115 ui.panel(x, y, side, side, BACKDROP);
116 // Edges are rounded rather than sizes, so two merged rectangles that shared a
117 // cell edge in the world still share a pixel edge here and leave no seam.
118 const block = (box: Box, fill: string): void => {
119 const left = Math.round(atX(box.min[0]));
120 const top = Math.round(atY(box.max[1]));
121 ui.panel(left, top, Math.round(atX(box.max[0])) - left, Math.round(atY(box.min[1])) - top, fill);
122 };
123 for (const box of plan.floor) block(box, FLOOR);
124 for (const site of plan.sites) for (const box of site.boxes) block(box, SITE);
125
126 // The HUD's small font, to the number: the overlay's scale has three sizes on
127 // it and a map that invented a fourth would be the only thing on the screen
128 // written in it.
129 const font = ui.font(15);
130 for (const site of plan.sites) {
131 const at = level.sites[site.letter];
132 ui.text(site.letter, atX(at[0]), atY(at[1]) + 5 * ui.scale, { align: "center", font, fill: LABEL });
133 }
134
135 const dot = (at: Vec3, radius: number, fill: string): void => {
136 ctx.fillStyle = fill;
137 ctx.beginPath();
138 ctx.arc(atX(at[0]), atY(at[1]), radius, 0, TAU);
139 ctx.fill();
140 };
141 const mark = (at: Vec3, half: number, fill: string): void => {
142 ui.panel(Math.round(atX(at[0]) - half), Math.round(atY(at[1]) - half), half * 2, half * 2, fill);
143 };
144
145 // --- the bomb. Planted, it is everybody's business and blinks where it lies;
146 // on the floor it is the terrorists' to pick up and only they are shown it.
147 const bomb = world.bomb;
148 const radius = Math.max(2, 3 * ui.scale);
149 if (bomb.state === "planted") mark(bomb.pos, radius + 1, world.time % 1 < 0.5 ? BOMB_ON : BOMB_OFF);
150 else if (bomb.state === "dropped" && view.team === "t") mark(bomb.pos, radius, BOMB_LOOSE);
151
152 // --- the players. Your side always; the other side while somebody alive on
153 // yours can see them, which is asked of the level's own geometry.
154 const mates = world.players.filter((p) => p.alive && p.team === view.team);
155 for (const player of world.players) {
156 if (!player.alive || player === view) continue;
157 const mine = player.team === view.team;
158 if (!mine && !mates.some((mate) => canSee(world, mate, player))) continue;
159 // Who is carrying it, on either side: a halo under the dot, drawn first so
160 // the dot still says which side they are on.
161 if (player.hasBomb) dot(player.pos, radius + 2, BOMB_LOOSE);
162 dot(player.pos, radius, mine ? teamColor(player.team) : RED);
163 }
164
165 // --- you, last and on top: a wedge pointing wherever you are looking. Dead
166 // with nobody left to watch, the view is your own body and there is no wedge:
167 // a corpse is not facing anywhere.
168 if (view.alive) {
169 if (view.hasBomb) dot(view.pos, radius + 4, BOMB_LOOSE);
170 wedge(ctx, atX(view.pos[0]), atY(view.pos[1]), view.yaw, radius + 3, WHITE);
171 }
172 }
173
174 /**
175 * An arrowhead at (`px`, `py`) pointing along `yaw`. Yaw is degrees about the
176 * world's Z, zero east and rising north; the screen's Y runs the other way,
177 * which is the one minus sign in here.
178 */
179 function wedge(ctx: Paint, px: number, py: number, yaw: number, radius: number, color: string): void {
180 const a = (yaw * Math.PI) / 180;
181 const point = (turn: number, reach: number): void => {
182 ctx.lineTo(px + Math.cos(a + turn) * radius * reach, py - Math.sin(a + turn) * radius * reach);
183 };
184 ctx.fillStyle = color;
185 ctx.beginPath();
186 ctx.moveTo(px + Math.cos(a) * radius, py - Math.sin(a) * radius);
187 point(2.4, 0.9);
188 point(Math.PI, 0.25);
189 point(-2.4, 0.9);
190 ctx.fill();
191 }
src/client/paint.test-helper.ts added +50
@@ -0,0 +1,50 @@
1 /**
2 * A paint that remembers: the HUD's surface, recording every call *and the
3 * brush that made it*, because on this game's 2D the colour a thing was drawn
4 * in is half of what a test is about — a health number goes red, a teammate is
5 * yellow, an enemy is not.
6 *
7 * It was `hud.test.ts`'s own until the radar arrived and `minimap.test.ts`
8 * wanted the same recorder; a second copy of it is a copy that drifts. The two
9 * recorders already in the repository are no use to either: `overlay.test.ts`'s
10 * and `stage.test-helper.ts`'s write down the call and throw the brush away.
11 *
12 * It ships in no bundle — nothing under `src/client/` imports it but tests —
13 * and it touches no global: no canvas, no document, no browser.
14 */
15
16 import type { Paint } from "./hud.ts";
17
18 /** One call a surface was given, and the state of the brush that made it. */
19 export type Call = {
20 op: string;
21 args: readonly unknown[];
22 fill: string;
23 stroke: string;
24 font: string;
25 align: string;
26 alpha: number;
27 };
28
29 export type Recorder = Paint & { calls: Call[] };
30
31 export function recorder(): Recorder {
32 const calls: Call[] = [];
33 const note = (op: string) => (...args: unknown[]): void => void calls.push({
34 op,
35 args,
36 fill: paint.fillStyle,
37 stroke: paint.strokeStyle,
38 font: paint.font,
39 align: paint.textAlign,
40 alpha: paint.globalAlpha,
41 });
42 const paint: Recorder = {
43 calls,
44 fillStyle: "", strokeStyle: "", lineWidth: 0, font: "", textAlign: "", textBaseline: "", globalAlpha: 1,
45 clearRect: note("clearRect"), fillRect: note("fillRect"), fillText: note("fillText"),
46 beginPath: note("beginPath"), moveTo: note("moveTo"), lineTo: note("lineTo"), rect: note("rect"),
47 arc: note("arc"), fill: note("fill"), stroke: note("stroke"),
48 };
49 return paint;
50 }
src/client/palette.ts added +23
@@ -0,0 +1,23 @@
1 /**
2 * The colours this game's 2D is drawn in, and the one rule about them: a side
3 * is always the same colour, wherever a side is drawn.
4 *
5 * It is a file of its own because there are two surfaces reading it now. The
6 * HUD had the palette to itself until `minimap.ts` drew the same ten players
7 * from above, and a second copy of `#e2b04a` is a copy that goes stale the day
8 * somebody retunes one of them. Nothing here knows what is drawn or where — it
9 * is a list of strings and one lookup.
10 */
11
12 import type { Team } from "./match.ts";
13
14 export const T_COLOR = "#e2b04a";
15 export const CT_COLOR = "#6fa8e8";
16 export const WHITE = "#f3f5f8";
17 export const RED = "#ff6b5b";
18 export const BLUE = "#9fd0ff";
19 export const GREY = "#c8ccd4";
20 export const CROSSHAIR = "rgba(120, 255, 140, 0.9)";
21
22 /** The colour a side is drawn in, on the HUD and on the radar alike. */
23 export const teamColor = (team: Team): string => (team === "t" ? T_COLOR : CT_COLOR);
src/games.ts +10
@@ -225,6 +225,16 @@ export type Game = {
225225 * the solver's two new branches ship with the rest of it. Neither other bundle
226226 * moved a byte for this pull: nothing outside `src/range/` changed here.
227227 * Measured 2026-09-20 with #142, #152 and #158 merged in.
228 * #176 gave the shooter a radar — `src/client/minimap.ts`, the level from above
229 * in the corner of the HUD — and the app grew 1,875 raw / 935 gzipped bytes for
230 * it: the module itself, the palette the HUD and the map now share, and the
231 * engine's `mergeSolid` called a second way (it was already bundled, for the
232 * geometry, so what shipped here is the radar's own drawing and nothing else).
233 * Measured 2026-09-20 after #176: app 86,909 raw / 39,626 gzipped, which fits
234 * the 88,000 / 40,500 #142 set with 1,091 raw and 874 gzipped bytes to spare, so
235 * the ceiling did not move — but like the range's after #88 that is the thinnest
236 * headroom on this page, and the next thing the shooter gains will want it
237 * raised. Neither other bundle moved a byte: nothing outside `src/client/` did.
228238 */
229239 export const GAMES: readonly Game[] = [
230240 {
src/pages/game.ts +2 −1
@@ -70,7 +70,8 @@ const SIGN_IN = "/__forge/signin?to=%2F";
7070 const menu = (visitor: Who): string => `
7171 <h2>Defuse or detonate</h2>
7272 <p>Five against five on <b>de_forge</b>, or on a map generated for the match. Bomb rounds,
73 CS movement and economy, bots that push the sites.</p>
73 CS movement and economy, bots that push the sites, and a radar in the corner so a map
74 nobody has ever walked is still a place you can find your way around.</p>
7475 <p class="hint">${visitor.role === "anon"
7576 ? `You are playing as a guest, so the leaderboard lists your score as <b>a guest's</b>.
7677 <a href="${SIGN_IN}" rel="noreferrer">Sign in to the forge</a> to play under your own name.`
static/app.9a24651e0a.js added +58

Diff not shown — a line 68,704 characters wide (limit 2,000). +58

View file
static/app.e248e1964e.js deleted −58

Diff not shown — a line 66,829 characters wide (limit 2,000). −58

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs