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

forked from trevato/cs

See the map before you play it: a plan of the layout on the menu, drawn from the level itself #218

Merged opened this

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

Merged

vesper/cs-engine:vesper/149-see-the-map-before-you-play-it-a-plan-of → main 1 commit fork

  • Merged. vesper/149-see-the-map-before-you-play-it-a-plan-of was merged into main as 22cce57.

Review

Sign in to review this change.
11 files changed +583 −93
README.md +16
@@ -239,6 +239,19 @@ the floor for the terrorists to come back for, and blinking red where it was
239239 planted, for both sides. It is the only thing on the screen that says where you
240240 are on a map generated for that one match, which is why #176 built it.
241241
242 The same drawing is on the menu, before you press Play: a plan of the map the
243 button would start — the rooms, the two bombsites lettered, and the two spawns
244 in the sides' own colours, which is the one thing a round never shows you.
245 Picking **de_forge** shows de_forge, picking **generated** shows the map this
246 match's id carves, and typing a name shows that exact map. It is the radar's
247 `drawPlan` given a whole level rather than a second drawing of the same data,
248 and it is drawn once per choice rather than once a frame: a choice has to stand
249 still for 200 ms before the level behind it is carved, so typing `gen_14u5j6g`
250 a character at a time costs one map and not ten, and nothing is drawn at all
251 while a match is running. Beside it is the same map in words — the size of the
252 grid, and where the sites and the spawns are by the compass — so the canvas is
253 hidden from the accessibility tree instead of being announced as nothing.
254
242255 Killed, you watch the rest of the round out of a teammate's eyes: fire moves
243256 to the next player on your side and aim moves back one, the camera is *placed*
244257 on whoever you picked — asking for someone else is a cut, never a flight across
@@ -549,6 +562,9 @@ never checked itself.
549562 every frame, north up, with your wedge, your side, the enemies your side can
550563 see right now and the bomb — it knows no alphabet, so floor is whatever the
551564 level calls passable and a crate is a dark block in a room),
565 `preview` (the menu's plan of the map Play would start: `minimap`'s own
566 `drawPlan` fitted to a small square, with both sides' spawns on it and the
567 sentence a screen reader is given instead of the picture),
552568 `palette` (what colour a side is, in the one place both surfaces read it),
553569 `spectate` (whose eyes a frame
554570 is drawn from once you are dead, and the camera that eases onto them),
src/client/main.test.ts +101
@@ -30,6 +30,8 @@ import { boot, COPIED, gameMeshes, NOT_AN_ID, NOT_A_MAP, NOT_COPIED, type Frame
3030 import { DE_FORGE } from "./map.ts";
3131 import { SHORT } from "./match.ts";
3232 import { LAYERS, materialTextures } from "./materials.ts";
33 import { recorder } from "./paint.test-helper.ts";
34 import { drawPreview, previewText } from "./preview.ts";
3335 import {
3436 FIRST_WAIT, LONGEST_WAIT, LOST, MAX_TRIES, REFUSED, RETRYING, SAVED, waitAfter,
3537 } from "./pending.ts";
@@ -910,6 +912,105 @@ test("copy this map is a link first and a clipboard second", async () => {
910912 expect(stage.copied.length).toBe(2);
911913 });
912914
915 // --- the map you can see before you play it -------------------------------
916
917 /**
918 * How long a choice has to stand still before the plan of it is drawn, in
919 * frames of this test's clock. It is `PLAN_WAIT` in `main.ts` — 200 ms — and it
920 * is written as a count of frames because that is the only clock a test has.
921 */
922 const SETTLE = Math.ceil(200 / FRAME) + 1;
923
924 /** The stub canvas's size, which is what a browser reports before any CSS. */
925 const PLAN_WIDTH = 800;
926 const PLAN_HEIGHT = 450;
927
928 /** Every 2D call the menu's plan canvas has been given, oldest first. */
929 const planCalls = (stage: FakeStage): [string, ...unknown[]][] => stage.plan.paint.calls;
930
931 /** What `drawPreview` puts on a canvas this size, for a level this test names. */
932 function planFor(level: Level): [string, ...unknown[]][] {
933 const paint = recorder();
934 drawPreview(paint, level, PLAN_WIDTH, PLAN_HEIGHT);
935 return paint.calls.map((call) => [call.op, ...call.args] as [string, ...unknown[]]);
936 }
937
938 test("the menu draws the map Play would start, once the choice has stood still", () => {
939 const stage = painted(start(fakeStage()));
940 // Nothing is drawn while the atlas is being painted: the menu is answerable
941 // and the frames belong to the browser.
942 expect(planCalls(stage).length).toBe(0);
943 expect(stage.by("planalt").textContent).toBe("");
944
945 // One frame past the wait and the plan of de_forge is on the canvas, at the
946 // size the browser is giving us of it, with the sentence beside it.
947 for (let frame = 0; frame < SETTLE; frame++) stage.step(FRAME);
948 expect([stage.plan.width, stage.plan.height]).toEqual([PLAN_WIDTH, PLAN_HEIGHT]);
949 expect(planCalls(stage)).toEqual(planFor(DE_FORGE));
950 expect(stage.by("planalt").textContent).toBe(previewText(DE_FORGE));
951
952 // Once per choice and not once a frame: the frames after it draw nothing.
953 const drawn = planCalls(stage).length;
954 for (let frame = 0; frame < 10; frame++) stage.step(FRAME);
955 expect(planCalls(stage).length).toBe(drawn);
956
957 // A new map every match: the plan is the map that id carves, which is the map
958 // the menu just named and the map Play is about to start.
959 stage.by("map").value = "generated";
960 stage.by("map").fire("change");
961 const level = generateLevel(seedFrom(stage.ids[0] as string));
962 expect(stage.by("willplay").textContent).toBe(level.name);
963 for (let frame = 0; frame < SETTLE; frame++) stage.step(FRAME);
964 expect(planCalls(stage).slice(drawn)).toEqual(planFor(level));
965 expect(stage.by("planalt").textContent).toBe(previewText(level));
966
967 // And it is that map that is played, with no second id drawn for it.
968 stage.by("play").fire("click");
969 expect(worldOf(stage)?.level.name).toBe(level.name);
970 expect(stage.ids.length).toBe(1);
971 });
972
973 test("a name typed a character at a time costs one map, and none while a match runs", () => {
974 const stage = painted(start(fakeStage()));
975 for (let frame = 0; frame < SETTLE; frame++) stage.step(FRAME);
976 const drawn = planCalls(stage).length;
977
978 // Typing: a keystroke a frame, which is faster than the wait, so no map is
979 // carved for any of the prefixes — `gen_1` is a map, and not the one meant.
980 for (const upto of [5, 6, 7, 8, 9, 10, 11]) {
981 stage.by("mapseed").value = MAP.slice(0, upto);
982 stage.by("mapseed").fire("input");
983 stage.step(FRAME);
984 }
985 expect(planCalls(stage).length).toBe(drawn);
986
987 // Hands off the keyboard, and the map they named is on the screen.
988 for (let frame = 0; frame < SETTLE; frame++) stage.step(FRAME);
989 const named = generateLevel(seedOf(MAP) as number);
990 expect(planCalls(stage).slice(drawn)).toEqual(planFor(named));
991
992 // A name that is not one is de_forge on the plan too, like everywhere else.
993 const typed = planCalls(stage).length;
994 stage.by("mapseed").value = "gen_0ya";
995 stage.by("mapseed").fire("input");
996 for (let frame = 0; frame < SETTLE; frame++) stage.step(FRAME);
997 expect(planCalls(stage).slice(typed)).toEqual(planFor(DE_FORGE));
998
999 // Nothing is drawn while a match is running: the menu is not on the screen
1000 // and the frame belongs to the round.
1001 const before = planCalls(stage).length;
1002 stage.by("mapseed").value = MAP;
1003 stage.by("mapseed").fire("input");
1004 stage.by("play").fire("click");
1005 for (let frame = 0; frame < SETTLE + 10; frame++) stage.step(FRAME);
1006 expect(planCalls(stage).length).toBe(before);
1007
1008 // Back at the menu it is drawn again, for the map the next Play would start.
1009 stage.by("quit").fire("click");
1010 for (let frame = 0; frame < SETTLE; frame++) stage.step(FRAME);
1011 expect(planCalls(stage).slice(before)).toEqual(planFor(named));
1012 });
1013
9131014 /**
9141015 * A match played from one id: the page hands that id out instead of a fresh
9151016 * one, Play is pressed, and `frames` frames are stepped. The id is the only
src/client/main.ts +77 −9
@@ -18,7 +18,10 @@
1818 * with. The menu names the map Play is about to give you *before* Play, which
1919 * is why the id for a match that wants a new map is drawn when the menu asks
2020 * rather than when the button is pressed, and the link beside that name is the
21 * map by name: press it and you get that layout, not another new one.
21 * map by name: press it and you get that layout, not another new one. Beside
22 * that name is a plan of the map itself, drawn through `preview.ts` once per
23 * choice — never per frame and never during a match — so what a player picks is
24 * a place and not a number.
2225 *
2326 * A round that did not land is not lost quietly. The result of every round is
2427 * posted as it is scored, and a post that threw or came back anything but 200
@@ -63,6 +66,7 @@ import { COMPETITIVE, SHORT, type Rules } from "./match.ts";
6366 import { LAYERS, MATERIALS, TEX_SIZE } from "./materials.ts";
6467 import { meshFor } from "./models.ts";
6568 import { nextStep, type Answer } from "./pending.ts";
69 import { drawPreview, previewText } from "./preview.ts";
6670 import { buildScene, createScene, SKY, type Scene } from "./scene.ts";
6771 import { createSpectator, cycle, follow } from "./spectate.ts";
6872 import { buyable, WEAPONS } from "./weapons.ts";
@@ -71,6 +75,16 @@ import { createWorld, eyePos, update, type Player, type World } from "./world.ts
7175
7276 const TICK = 1 / 64;
7377
78 /**
79 * How still the Map controls have to be before the map they name is carved and
80 * drawn, in milliseconds. It is a debounce rather than a blur handler because
81 * the choice is three controls and a person moves between them — a blur is not
82 * the moment a choice was made. 200 ms is longer than the gap between two
83 * keystrokes of a name being typed and shorter than a person noticing, so
84 * `gen_14u5j6g` costs one map and the picture is there when they look up.
85 */
86 const PLAN_WAIT = 200;
87
7488 /**
7589 * What the menu says when the field holds something that is not a match id.
7690 * A typo is not an error page and not a refusal to play: the match starts, it
@@ -107,19 +121,21 @@ export type Pressable = Listens & { type: string; textContent: string | null; di
107121 /** The box the canvases sit in: what goes full screen, when a browser will. */
108122 export type Box = Panel & { requestFullscreen?(): unknown };
109123
110 /** A button that says which of two states it is in — the full screen one does. */
111 export type Toggle = Panel & { setAttribute(name: string, value: string): void };
112
113 /** A canvas on the stage: the scene is drawn into, the HUD is painted on. */
114 export type Stage = Panel & {
124 /** A canvas this page only ever draws on: the plan of the map, under the menu. */
125 export type Sheet = Panel & {
115126 readonly clientWidth: number;
116127 readonly clientHeight: number;
117128 width: number;
118129 height: number;
119130 getContext(kind: "2d"): Paint | null;
120 requestPointerLock(): unknown;
121131 };
122132
133 /** A button that says which of two states it is in — the full screen one does. */
134 export type Toggle = Panel & { setAttribute(name: string, value: string): void };
135
136 /** A canvas on the stage: the scene is drawn into, the HUD is painted on. */
137 export type Stage = Sheet & { requestPointerLock(): unknown };
138
123139 export type PageDocument = Listens & {
124140 readonly pointerLockElement: unknown;
125141 /** Whose element is on the screen right now — the engine's `createFullscreen` reads it. */
@@ -262,8 +278,11 @@ export function boot(host: Host): Frame {
262278 // The Map control's other half: one exact map, by name. It wins over the
263279 // select, because a person who typed a name meant that map.
264280 const seedField = by<Field>("mapseed");
265 // The name of the map Play is about to give you, and the link that keeps it.
281 // The name of the map Play is about to give you, the plan of it, the line
282 // that says in words what the plan says in pixels, and the link that keeps it.
266283 const willPlay = by("willplay");
284 const planCanvas = by<Sheet>("plan");
285 const planAlt = by("planalt");
267286 const copyMap = by<Toggle>("copymap");
268287 const copyLabel = copyMap.textContent;
269288 const prep = by("prep");
@@ -282,6 +301,7 @@ export function boot(host: Host): Frame {
282301 // principle have been called before the check ran.
283302 const build = maybeBuild;
284303 const hudCtx = hudCanvas.getContext("2d") as Paint;
304 const planCtx = planCanvas.getContext("2d") as Paint;
285305 const audio = host.createAudio();
286306 const controls = createControls(
287307 { surface: sceneCanvas, window: win, document: doc },
@@ -373,6 +393,17 @@ export function boot(host: Host): Frame {
373393 let drawn: string | null = null;
374394 /** The URL the copy control is holding, which is the one it puts on the clipboard. */
375395 let keeping = mapLink("de_forge");
396 /**
397 * When the plan of the chosen map is due to be drawn, on the frame clock, or
398 * null when what is on the canvas is already the map Play would start.
399 * Carving a level is about six milliseconds and the Map name field fires on
400 * every keystroke, so a choice has to stand still for `PLAN_WAIT` before it
401 * costs anything: `gen_14u5j6g` typed a character at a time is one map drawn,
402 * not ten. The drawing itself is not the price — it is three hundredths of a
403 * millisecond, because the rectangles are merged once per level. Scheduled here and spent in the frame loop, which is the only clock
404 * this file has and the only place a slow frame can be seen.
405 */
406 let planAt: number | null = null;
376407 // The map the renderer is holding the geometry of. It starts as the one the
377408 // meshes were built from, and a match on another map swaps that one mesh.
378409 let shown: Level = DE_FORGE;
@@ -622,6 +653,35 @@ export function boot(host: Host): Frame {
622653 copyMap.setAttribute("href", keeping);
623654 copyMap.textContent = copyLabel;
624655 onMap.textContent = badName() ? NOT_A_MAP : aboutMap;
656 // The picture of it is not drawn here: naming a map is a string and drawing
657 // one is a level, and this runs on every keystroke. See `PLAN_WAIT`.
658 planAt = win.performance.now() + PLAN_WAIT;
659 }
660
661 /**
662 * The plan of the map Play would start, drawn on the small canvas beside its
663 * name, and the sentence beside that for anyone who is not looking at it. The
664 * level is chosen exactly the way `start` below chooses it — same fields, same
665 * seed, same `levelOfChoice` — so the picture is the map, not a map like it.
666 *
667 * `generated` is the only choice that needs the match's id, so it is the only
668 * one that draws one. Picking de_forge or typing a name hands out no id here,
669 * exactly as naming that map on the line above hands out none.
670 */
671 function drawMapPlan(): void {
672 const choice = chosenMap();
673 const level = levelOfChoice(choice, choice === "generated" ? seedFrom(nextMatch()) : 0);
674 // Device pixels, like the HUD's canvas: the CSS box is a fixed square and
675 // this is what the browser is actually giving us of it.
676 const dpr = Math.min(win.devicePixelRatio || 1, 2);
677 const w = Math.round(planCanvas.clientWidth * dpr);
678 const h = Math.round(planCanvas.clientHeight * dpr);
679 if (planCanvas.width !== w || planCanvas.height !== h) {
680 planCanvas.width = w;
681 planCanvas.height = h;
682 }
683 drawPreview(planCtx, level, w, h);
684 planAlt.textContent = previewText(level);
625685 }
626686
627687 for (const field of [mapField, seedField, idField])
@@ -779,7 +839,15 @@ export function boot(host: Host): Frame {
779839 // The round that has not landed, tried again on the frame its backoff runs
780840 // out — including after the match is over, when this is the only frame left
781841 // that runs. It is four comparisons and no work at all in the common case.
782 send(win.performance.now());
842 const now = win.performance.now();
843 send(now);
844 // The plan of the map the menu is offering, once the choice has stood still
845 // — and never while a match is running, where the frame belongs to the round
846 // and the menu is not on the screen to look at anyway.
847 if (planAt !== null && !running && now >= planAt) {
848 planAt = null;
849 drawMapPlan();
850 }
783851 if (world === null || !running) return;
784852
785853 const me = world.me;
src/client/minimap.ts +47 −16
@@ -26,6 +26,12 @@
2626 * the radar while somebody alive on your side can see them, and off it the
2727 * moment nobody can. There is no memory of where one was a second ago, which is
2828 * deliberate — a dot that lingers is a dot that lies.
29 *
30 * Half of it is not about a round at all: a level fitted to a square, its floor,
31 * its two sites and the letters on them. That half is `drawPlan`, and the menu
32 * draws the map you are about to play with it — see `src/client/preview.ts`. It
33 * hands back the projection it used, because a caller that wants to put anything
34 * else on the square needs exactly the arithmetic that put the rooms there.
2935 */
3036
3137 import { mergeSolid, type Box, type Overlay, type Paint, type Vec3 } from "../engine/index.ts";
@@ -85,23 +91,26 @@ export function planOf(level: Level): Plan {
8591 }
8692
8793 /**
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 * Where a point of the world lands on the square a plan was drawn in. It is two
95 * functions rather than a matrix because that is all the projection is: one
96 * scale, one centre, and the one minus sign that puts north up.
9497 */
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;
98 export type Projection = {
99 readonly atX: (worldX: number) => number;
100 readonly atY: (worldY: number) => number;
101 };
102
103 /**
104 * The level itself, flat in the square at (`x`, `y`) of side `side`: the
105 * backdrop, every cell a player can stand on, the two bombsites over them and
106 * the letters they answer to. Nothing in here is about a round — no players, no
107 * bomb, no time — so the radar draws it every frame and the menu draws it once
108 * per choice, off the same rectangles.
109 *
110 * Hands back the projection it used, so whatever the caller puts on the square
111 * next lands where this put the rooms.
112 */
113 export function drawPlan(ui: Overlay, level: Level, x: number, y: number, side: number): Projection {
105114 const plan = planOf(level);
106115 // The map is centred on the world's origin and so is the square.
107116 const k = side / plan.span;
@@ -131,6 +140,28 @@ export function drawMinimap(
131140 const at = level.sites[site.letter];
132141 ui.text(site.letter, atX(at[0]), atY(at[1]) + 5 * ui.scale, { align: "center", font, fill: LABEL });
133142 }
143 return { atX, atY };
144 }
145
146 /**
147 * Draw the radar into the square at (`x`, `y`) of side `side`. `view` is whose
148 * eyes this frame is drawn from — you, or the player you are watching once you
149 * are dead — which decides which side is yours and who counts as an enemy.
150 *
151 * Where the square sits is the HUD's business, like every other position on the
152 * screen; what goes in it is this file's.
153 */
154 export function drawMinimap(
155 ctx: Paint,
156 ui: Overlay,
157 world: World,
158 view: Player,
159 x: number,
160 y: number,
161 side: number,
162 ): void {
163 // The level, and only the level: everything below is the round on top of it.
164 const { atX, atY } = drawPlan(ui, world.level, x, y, side);
134165
135166 const dot = (at: Vec3, radius: number, fill: string): void => {
136167 ctx.fillStyle = fill;
src/client/preview.test.ts added +129
@@ -0,0 +1,129 @@
1 /**
2 * What a player sees on the menu before they press Play: a whole level in a
3 * square, its two bombsites lettered and its two spawns marked in the colours
4 * the sides are drawn in everywhere else.
5 *
6 * It reads frames off `paint.test-helper.ts`'s recorder the way `hud.test.ts`
7 * and `minimap.test.ts` do — the brush as well as the call, because the colour
8 * of a rectangle is what says whose spawn it is — and it pins the claims that
9 * are about the picture rather than about the arrangement: everything inside
10 * the box, the box square whatever shape the canvas is, north up, and the
11 * sentence beside it made of the level and not of a template.
12 *
13 * No canvas, no document, no browser.
14 */
15
16 import { expect, test } from "bun:test";
17 import { generateLevel } from "./generate.ts";
18 import type { Level } from "./level.ts";
19 import { DE_FORGE } from "./map.ts";
20 import { CT_COLOR, T_COLOR } from "./palette.ts";
21 import { compass, drawPreview, previewText } from "./preview.ts";
22 import { recorder, type Call, type Recorder } from "./paint.test-helper.ts";
23
24 /** The colours this file reads back, which are `minimap.ts`'s own. */
25 const BACKDROP = "rgba(8, 10, 14, 0.62)";
26 const FLOOR = "rgba(146, 156, 174, 0.5)";
27
28 /** A map nobody wrote, so nothing below is about de_forge in particular. */
29 const GENERATED = generateLevel(12345);
30
31 const draw = (level: Level, width = 192, height = 192): Recorder => {
32 const paint = recorder();
33 drawPreview(paint, level, width, height);
34 return paint;
35 };
36
37 const ops = (paint: Recorder, op: string): Call[] => paint.calls.filter((c) => c.op === op);
38 const inColor = (calls: Call[], color: string): Call[] => calls.filter((c) => c.fill === color);
39 const box = (call: Call): number[] => call.args.slice(0, 4).map(Number);
40
41 test("the whole level is in the square: its rooms, its two sites, its two spawns", () => {
42 for (const level of [DE_FORGE, GENERATED]) {
43 const paint = draw(level);
44 const rects = ops(paint, "fillRect");
45
46 // The backdrop is the square and the square is the canvas.
47 const backdrop = inColor(rects, BACKDROP);
48 expect(backdrop.length).toBe(1);
49 expect(box(backdrop[0] as Call)).toEqual([0, 0, 192, 192]);
50
51 // Rooms, and the two sites lettered in reading order.
52 expect(inColor(rects, FLOOR).length).toBeGreaterThan(10);
53 expect(ops(paint, "fillText").map((c) => c.args[0])).toEqual(["A", "B"]);
54
55 // Both spawns, one mark per cell a side starts a round on — and they are
56 // the sides' own colours, so a player reads which end is theirs.
57 expect(inColor(rects, T_COLOR).length).toBe(level.spawns.t.length);
58 expect(inColor(rects, CT_COLOR).length).toBe(level.spawns.ct.length);
59
60 // Nothing is drawn outside the box, in either direction.
61 for (const rect of rects) {
62 const [x, y, w, h] = box(rect) as [number, number, number, number];
63 expect(x).toBeGreaterThanOrEqual(0);
64 expect(y).toBeGreaterThanOrEqual(0);
65 expect(x + w).toBeLessThanOrEqual(192);
66 expect(y + h).toBeLessThanOrEqual(192);
67 }
68 for (const text of ops(paint, "fillText")) {
69 expect(text.args[1] as number).toBeGreaterThan(0);
70 expect(text.args[1] as number).toBeLessThan(192);
71 expect(text.args[2] as number).toBeGreaterThan(0);
72 expect(text.args[2] as number).toBeLessThan(192);
73 }
74 }
75 });
76
77 test("north is up and the sides are at opposite ends of it", () => {
78 const paint = draw(GENERATED);
79 const middle = (calls: Call[]): number =>
80 calls.reduce((sum, c) => sum + (c.args[1] as number), 0) / calls.length;
81 // The terrorists spawn in the south of this map and the counter-terrorists in
82 // the north, so on the square the T marks are the lower ones.
83 const t = middle(inColor(ops(paint, "fillRect"), T_COLOR));
84 const ct = middle(inColor(ops(paint, "fillRect"), CT_COLOR));
85 expect(GENERATED.spawns.t[0]?.[1]).toBeLessThan(0);
86 expect(t).toBeGreaterThan(ct);
87 });
88
89 test("a canvas that is not square still gets a square map, centred", () => {
90 // The stub page's canvas is 800 by 450, which is what a browser hands back
91 // before any CSS has been applied to it.
92 const paint = draw(DE_FORGE, 800, 450);
93 const backdrop = inColor(ops(paint, "fillRect"), BACKDROP)[0] as Call;
94 expect(box(backdrop)).toEqual([175, 0, 450, 450]);
95 // It is cleared first: the map before it was a different shape.
96 expect(paint.calls[0]?.op).toBe("clearRect");
97 expect(box(paint.calls[0] as Call)).toEqual([0, 0, 800, 450]);
98 // And a canvas with no pixels in it is nothing drawn rather than a divide by
99 // zero: a browser hands one of those back for a hidden element.
100 expect(draw(DE_FORGE, 0, 0).calls.length).toBe(1);
101 });
102
103 test("the line beside it is the level, in words", () => {
104 const line = previewText(GENERATED);
105 const { grid } = GENERATED;
106 expect(line).toContain(`${grid.width}×${grid.height} cells`);
107 expect(line).toContain("bombsite A north-east");
108 expect(line).toContain("B south-west");
109 // Where each side starts, which is the one thing on the plan a round never
110 // shows you: this map spawns the terrorists south and the CTs north.
111 expect(line).toContain("spawns south and north");
112 // It does not repeat the map's name — the name is the element above it.
113 expect(line).not.toContain(GENERATED.name);
114
115 // de_forge is a different map and gets a different sentence.
116 expect(previewText(DE_FORGE)).not.toBe(line);
117 expect(previewText(DE_FORGE)).toContain("44×44 cells");
118 });
119
120 test("a compass word is dropped when a thing is not really off centre", () => {
121 const SPAN = 1000;
122 expect(compass([400, 400, 0], SPAN)).toBe("north-east");
123 expect(compass([-400, -400, 0], SPAN)).toBe("south-west");
124 expect(compass([0, 400, 0], SPAN)).toBe("north");
125 expect(compass([-400, 0, 0], SPAN)).toBe("west");
126 expect(compass([0, 0, 0], SPAN)).toBe("the middle");
127 // Inside a twelfth of the map is the middle of it, not faintly north of it.
128 expect(compass([10, -20, 0], SPAN)).toBe("the middle");
129 });
src/client/preview.ts added +105
@@ -0,0 +1,105 @@
1 /**
2 * The plan of the map on the menu: the layout Play is about to start, before
3 * Play. Choosing a generated map used to be choosing a number — the menu named
4 * it, and the name told you nothing about the place — so this is the level
5 * itself, drawn small beside that name.
6 *
7 * It is the radar's drawing given a whole level instead of a corner of one:
8 * `drawPlan` in `./minimap.ts` is the projection and the merged floor
9 * rectangles, and what is added here is the one thing a round has that a menu
10 * does not — the two spawns, which nobody can see from inside a match and
11 * everybody wants to see before one.
12 *
13 * `previewText` is what a screen reader gets instead of the picture: the size of
14 * the grid and where the sites and the spawns are, in compass words off the
15 * level's own coordinates. The canvas itself is hidden from the accessibility
16 * tree by the page — a canvas announced is a canvas announced as nothing.
17 *
18 * Nothing here is a browser: it takes a `Paint` and two numbers, which is a real
19 * 2D context in a page and a recorder in a test.
20 */
21
22 import { overlay, type Overlay, type Paint, type Vec3 } from "../engine/index.ts";
23 import type { Level } from "./level.ts";
24 import { drawPlan, type Projection } from "./minimap.ts";
25 import type { Team } from "./match.ts";
26 import { teamColor } from "./palette.ts";
27
28 /**
29 * How far off centre a thing has to be before it is called north or east, as a
30 * share of the map's longer side. Under it the word is dropped: a site in the
31 * middle of a map is in the middle of it, not faintly north of it.
32 */
33 const OFF_CENTRE = 0.08;
34
35 /** The middle of a cloud of points — the five cells a side spawns on. */
36 function middleOf(points: readonly Vec3[]): Vec3 {
37 if (points.length === 0) return [0, 0, 0];
38 let x = 0;
39 let y = 0;
40 for (const point of points) {
41 x += point[0];
42 y += point[1];
43 }
44 return [x / points.length, y / points.length, 0];
45 }
46
47 /**
48 * Where a point is on a map whose longer side is `span`, in the words a person
49 * would use: `north-east`, `south`, or `the middle` when it is neither.
50 */
51 export function compass(at: Vec3, span: number): string {
52 const edge = span * OFF_CENTRE;
53 const northSouth = at[1] > edge ? "north" : at[1] < -edge ? "south" : "";
54 const eastWest = at[0] > edge ? "east" : at[0] < -edge ? "west" : "";
55 if (northSouth === "" && eastWest === "") return "the middle";
56 return northSouth === "" || eastWest === "" ? northSouth + eastWest : `${northSouth}-${eastWest}`;
57 }
58
59 /**
60 * The line beside the picture: what this map is, in one sentence made of the
61 * level and nothing else. It does not repeat the map's name — the name is the
62 * element above it on the page — and it stays short because it sits in a column
63 * beside a 96-pixel square on a 360-pixel screen.
64 */
65 export function previewText(level: Level): string {
66 const { grid, sites, spawns } = level;
67 const span = Math.max(grid.width, grid.height) * grid.cell;
68 const where = (at: Vec3): string => compass(at, span);
69 return `${grid.width}×${grid.height} cells · bombsite A ${where(sites.A)}, B ${where(sites.B)} · ` +
70 `spawns ${where(middleOf(spawns.t))} and ${where(middleOf(spawns.ct))}`;
71 }
72
73 /**
74 * One side's spawn cells, as squares in that side's colour. A square is the
75 * cell it stands on and not a dot of its own: a side spawns on thirty of them
76 * and thirty dots big enough to see would be a blot over the rooms behind them.
77 */
78 function drawSpawns(ui: Overlay, at: Projection, points: readonly Vec3[], half: number, team: Team): void {
79 const fill = teamColor(team);
80 for (const point of points)
81 ui.panel(Math.round(at.atX(point[0])) - half, Math.round(at.atY(point[1])) - half, half * 2, half * 2, fill);
82 }
83
84 /**
85 * The whole preview, into a canvas `width` by `height` device pixels: the level
86 * in the largest square that fits, centred, with both sides' spawns on it. The
87 * square is what keeps a map a map on a canvas a browser rounded to something
88 * else — a plan stretched to a 16:9 box is a plan that lies about a corner.
89 *
90 * It clears first, because it is drawn once per choice rather than once a frame
91 * and the map before it was a different shape.
92 */
93 export function drawPreview(ctx: Paint, level: Level, width: number, height: number): void {
94 ctx.clearRect(0, 0, width, height);
95 const side = Math.min(width, height);
96 if (side <= 0) return;
97 const x = Math.round((width - side) / 2);
98 const y = Math.round((height - side) / 2);
99 const ui = overlay(ctx, side, side);
100 const at = drawPlan(ui, level, x, y, side);
101 // One cell of this map, in pixels of this square, and never under a pixel.
102 const half = Math.max(1, Math.round(side / Math.max(level.grid.width, level.grid.height) / 2));
103 drawSpawns(ui, at, level.spawns.t, half, "t");
104 drawSpawns(ui, at, level.spawns.ct, half, "ct");
105 }
src/client/stage.test-helper.ts +7 −4
@@ -112,6 +112,8 @@ export type FakeStage = {
112112 keyboard: FakeKeyboard;
113113 scene: StageCanvas;
114114 hud: StageCanvas;
115 /** The menu's small canvas: the plan of the map Play would start. */
116 plan: StageCanvas;
115117 by(id: string): FakeElement;
116118 /** The `[data-action]` buttons the page carries, in the page's order. */
117119 buttons: FakeElement[];
@@ -162,7 +164,7 @@ export type FakeStage = {
162164 * contract between the page and this file, so `src/server.test.ts` checks the
163165 * served page against this same list rather than a second copy of it.
164166 */
165 export const PAGE_IDS = ["stage", "scene", "hud", "menu", "pause", "buy", "touch", "stats", "saved", "name", "length", "side", "sens", "map", "mapseed", "matchid", "onid", "onmap", "willplay", "copymap", "play", "prep", "resume", "quit", "esc", "pauseid", "playing", "mapname", "share", "fps", "full"];
167 export const PAGE_IDS = ["stage", "scene", "hud", "menu", "pause", "buy", "touch", "stats", "saved", "name", "length", "side", "sens", "map", "mapseed", "matchid", "onid", "onmap", "willplay", "plan", "planalt", "copymap", "play", "prep", "resume", "quit", "esc", "pauseid", "playing", "mapname", "share", "fps", "full"];
166168
167169 /** The ones the server renders with `hidden` on them: the page starts at the menu. */
168170 const HIDDEN = ["pause", "buy", "touch", "full"];
@@ -196,15 +198,15 @@ export function fakeStage(
196198 ): FakeStage {
197199 const page = fakePage({
198200 ids: PAGE_IDS,
199 canvases: ["scene", "hud"],
201 canvases: ["scene", "hud", "plan"],
200202 hidden: HIDDEN,
201203 actions: PAGE_ACTIONS,
202204 coarse: options.coarse,
203205 fullscreen: options.fullscreen,
204206 keyboardLock: options.keyboardLock,
205207 });
206 // Asked for with `paint` left on, so both canvases have their 2D recorder.
207 const [scene, hud] = page.canvases as [StageCanvas, StageCanvas];
208 // Asked for with `paint` left on, so every canvas has its 2D recorder.
209 const [scene, hud, plan] = page.canvases as [StageCanvas, StageCanvas, StageCanvas];
208210 // The same switch on both sides of the seam: `webgl: false` is a browser with
209211 // no context to give, whether the frame asks this page's `host` for a renderer
210212 // or — as the committed bundle does — asks the canvas itself.
@@ -282,6 +284,7 @@ export function fakeStage(
282284 keyboard: page.keyboard,
283285 scene,
284286 hud,
287 plan,
285288 buttons: page.buttons,
286289 frames: page.frames,
287290 posts, draws, replaced, atlases, sounds, store, ids, copied,
src/games.ts +11
@@ -330,6 +330,17 @@ export type Game = {
330330 * cannot shorten. Neither other bundle moved a byte: `?map=` is the shooter's
331331 * parameter and the other two games neither read it nor import any of this.
332332 * Measured 2026-09-21.
333 * #149 drew that map on the menu — `src/client/preview.ts`, which is the
334 * radar's own `drawPlan` given a whole level, the two spawns on top of it and
335 * the sentence a screen reader gets instead — and the app grew 1,368 raw / 536
336 * gzipped bytes for it: 96,588 → 97,956 raw and 43,537 → 44,073 gzipped. Almost
337 * none of that is drawing: the projection and the merged floor rectangles were
338 * already in the bundle for the radar, and what is new is the debounce in the
339 * frame loop, the spawn marks, and the compass words, which a minifier cannot
340 * shorten. It fits the 100,000 / 45,000 #148 set with 2,044 raw and 927 gzipped
341 * bytes to spare, so the ceiling is held — and it is still the thinnest headroom
342 * on this page. Neither other bundle moved a byte: nothing outside
343 * `src/client/` and `src/pages/game.ts` changed. Measured 2026-09-21.
333344 */
334345 export const GAMES: readonly Game[] = [
335346 {
src/pages/game.ts +32 −6
@@ -95,11 +95,21 @@ a.share { display:inline-flex; align-items:center; min-height:44px; padding:0 .7
9595 background:#1b2230; border:1px solid var(--line); color:var(--ct); text-decoration:none; }
9696 a.share:hover, a.share:focus-visible { background:#27303f; text-decoration:underline; }
9797 #thismatch p { margin:.35rem 0; }
98 /* The name of the map Play is about to give you, and the link that keeps it.
99 It wraps rather than scrolls: at 360px the name is one line and the link is
100 the next, and both are still 44px to press. */
98 /* The plan of the map Play is about to give you, the name it answers to and the
99 link that keeps it: the picture on the left, the words on the right. It wraps
100 rather than scrolls, and at 360px the square and the column still fit side by
101 side — 96 + 8 + 224 inside the overlay's padding. */
101102 #willmap { display:flex; flex-wrap:wrap; gap:.5rem; align-items:center; justify-content:center;
102 margin:.35rem 0; }
103 margin:.35rem 0; width:100%; max-width:34rem; }
104 /* A fixed square, sized in CSS rather than by the canvas's own attributes, so
105 the space it takes is the same before it is drawn as after. */
106 #plan { flex:none; width:6rem; height:6rem; background:#0f131a; border:1px solid var(--line);
107 border-radius:.35rem; }
108 #willtext { flex:1 1 13rem; min-width:0; max-width:22rem; text-align:left; }
109 #willtext p { margin:.1rem 0; }
110 /* Two lines' worth, whether it is holding the sentence the server rendered or
111 the one the browser measured off the level: the line changes, the box does not. */
112 #planalt { min-height:2.5em; }
103113 #willplay { user-select:all; }
104114 `;
105115
@@ -112,6 +122,16 @@ export const BAD_MAP = "That link's map name is not one — this is de_forge ins
112122 /** What the line before Play says when the map is one a match has yet to draw. */
113123 export const UNDRAWN = "a new one every match";
114124
125 /**
126 * What stands in for the plan of the map until the browser has drawn one. The
127 * picture is a canvas and the sentence beside it is measured off the level, so
128 * neither exists in the first paint: a server that carved a map to describe it
129 * would be carving one per request. This is the true sentence for that moment,
130 * the length of the one that replaces it, and `src/client/main.ts` writes the
131 * real one in as soon as there is a map to describe.
132 */
133 export const UNPLANNED = "A plan of it is drawn here as soon as the game has booted.";
134
115135 /** The link that plays this match again: the id it names and the map it was on. */
116136 export const shareHref = (asked: Asked): string =>
117137 asked.match === null ? "/" : `/?match=${asked.match}&map=${asked.map}`;
@@ -179,8 +199,14 @@ const menu = (visitor: Who, asked: Asked): string => `
179199 cover, and the same map for both sides — it is the layout turned half a circle, so the walk
180200 to your site is the walk to theirs. A name in this field is that exact map, on any machine;
181201 empty, the Map control above decides.`}</p>
182 <p id="willmap">Map <b id="willplay">${escape(asked.map === "generated" ? UNDRAWN : asked.map)}</b>
183 <a class="share" id="copymap" href="${escape(mapLink(asked.map))}">Copy this map's link</a></p>
202 <div id="willmap">
203 <canvas id="plan" width="192" height="192" aria-hidden="true"></canvas>
204 <div id="willtext">
205 <p>Map <b id="willplay">${escape(asked.map === "generated" ? UNDRAWN : asked.map)}</b></p>
206 <p class="hint" id="planalt">${escape(UNPLANNED)}</p>
207 <a class="share" id="copymap" href="${escape(mapLink(asked.map))}">Copy this map's link</a>
208 </div>
209 </div>
184210 <div class="row"><button type="button" class="primary" id="play" disabled>Play</button></div>
185211 <p class="hint" id="prep">Preparing textures…</p>
186212 <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk ·
static/app.9e515111bc.js deleted −58

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

View file
static/app.e2fe4849eb.js added +58

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

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs