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

forked from trevato/cs

cant buy. im trying to buy but when i hit b the menu opens and my mouse is st… #358

Closed opened this in answer to
Closed without merging

vesper/cs-engine:vesper/355-cant-buy-im-trying-to-buy-but-when-i-hit → main 1 commit fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
10 files changed +288 −23
README.md +11 −4
@@ -397,9 +397,12 @@ time — **and buying never takes the mouse off the game**: the panel opens with
397397 the pointer still locked, every tile is printed with the key that buys it (the
398398 number row, <kbd>1</kbd> to <kbd>9</kbd>, then <kbd>0</kbd> and <kbd>-</kbd>,
399399 left to right), <kbd>X</kbd> buys last round's loadout again as far as the money
400 goes, and <kbd>B</kbd> or <kbd>Esc</kbd> closes. The tiles are still buttons, so
401 a thumb taps them and a keyboard tabs to them, and with the panel open the
402 trigger and the weapon keys are off the player while your feet still work —
400 goes, and <kbd>B</kbd> or <kbd>Esc</kbd> closes. The mouse buys too: with the
401 lock kept the browser draws no cursor, so the panel draws its own — moving the
402 mouse moves it instead of your view, and the left button presses the tile under
403 it. The tiles are still buttons, so a thumb taps them and a keyboard tabs to
404 them, and with the panel open the trigger and the weapon keys are off the player
405 while your feet still work —
403406 <kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> switch guns — and **a switch costs CS's deploy
404407 time**: the carry speed changes on the tick you press the key, so tapping
405408 <kbd>3</kbd> runs at the knife's 250 u/s straight away, but the weapon you come
@@ -867,7 +870,11 @@ never checked itself.
867870 an item: last round's loadout, bought again. It takes the element to draw
868871 into, the round and the player, and the calls that say what was pressed; it
869872 reaches for no document and buys nothing, so `main` spends the money in the
870 one place that holds a world),
873 one place that holds a world. It hands back every control it drew with what
874 pressing it means, so a click by the panel's own pointer is that control's click),
875 `cursor` (the buy panel's own pointer while the game keeps the mouse locked: a
876 point kept inside the panel, moved by the turn the controls measured turned
877 back into counts — one count, one pixel, whatever the sensitivity),
871878 `loadout` (what you bought, and buying it again: a round's purchases in the
872879 order they were made, rolled over on the round, and the rebuy that walks that
873880 list through `economy`'s `buy` — stepping over a gun you survived with and
src/client/buy.test.ts +24 −2
@@ -17,7 +17,7 @@
1717 import { expect, test } from "bun:test";
1818 import { NUMBER_ROW } from "./binding.ts";
1919 import { fakePage, type FakeElement } from "../engine/index.ts";
20 import { drawBuy, HINT, labelOf, offers, REBUY_LABEL, type Counter } from "./buy.ts";
20 import { controlAt, drawBuy, HINT, labelOf, offers, REBUY_LABEL, type Counter } from "./buy.ts";
2121 import { emptyAmmo, type BuyItem, type Buyer, type Market } from "./economy.ts";
2222 import { DE_FORGE } from "./map.ts";
2323 import { SHORT } from "./match.ts";
@@ -228,7 +228,8 @@ test("the hint under the grid says the keys, and an item answers to the name the
228228 const it = shop();
229229 drawBuy(it.counter, freeze, buyer());
230230 expect(it.panel.innerHTML).toContain(HINT);
231 expect(HINT).toContain("number row");
231 expect(HINT).toContain("Point and click");
232 expect(HINT).toContain("press the number printed on it");
232233 expect(HINT).toContain("X");
233234 expect(HINT).toContain("B or Escape closes");
234235 // The sentence a rebuy says is built out of these, so they are the shelf's
@@ -237,3 +238,24 @@ test("the hint under the grid says the keys, and an item answers to the name the
237238 expect(labelOf("helmet", "ct")).toBe("Kevlar + Helmet");
238239 expect(labelOf("kit", "ct")).toBe("Defuse kit");
239240 });
241
242 test("every control drawn comes back with its press, and a press at a point is only a live one's", () => {
243 const it = shop();
244 const controls = drawBuy(it.counter, freeze, buyer({ money: 800 }), ["ak47"]);
245 // Every tile and then the rebuy control, in the order they were drawn.
246 expect(controls.map((control) => control.tile)).toEqual([...it.tiles(), it.again()]);
247
248 // What the panel's pointer lands on is pressed exactly as a click on it is.
249 const pistol = it.tiles()[0];
250 controlAt(controls, pistol)?.act();
251 expect(it.pressed).toEqual([offers("ct")[0]?.item as BuyItem]);
252 controlAt(controls, it.again())?.act();
253 expect(it.rebuys()).toBe(1);
254
255 // A dead tile is as dead to the pointer as to a click, and a point over
256 // anything that is not a control — or over nothing — presses nothing.
257 expect(it.tile("AWP").disabled).toBe(true);
258 expect(controlAt(controls, it.tile("AWP"))).toBeNull();
259 expect(controlAt(controls, it.panel)).toBeNull();
260 expect(controlAt(controls, null)).toBeNull();
261 });
src/client/buy.ts +31 −5
@@ -12,7 +12,9 @@
1212 * the game: the hand that buys is the hand that is walking out of spawn, and a
1313 * tile you can only reach by pointing at it is a tile that costs a pointer
1414 * round-trip. Under them is the one control that is not an item: last round's
15 * loadout, bought again.
15 * loadout, bought again. The mouse still buys too: the panel draws its own
16 * pointer inside the lock (`cursor.ts`), and `drawBuy` hands back every control
17 * with what pressing it means so the trigger over a tile is that tile's click.
1618 *
1719 * It is not a browser and it is not the frame. It takes the element to draw
1820 * into, the two things a price is worked out from — the round and the player —
@@ -53,6 +55,14 @@ export type Shelf = {
5355 /** One tile in the grid: a button, its label, whether it can be pressed. */
5456 export type Tile = Listens & { type: string; textContent: string | null; disabled: boolean };
5557
58 /**
59 * A control the panel drew and what pressing it means. `drawBuy` hands the
60 * whole list back so a press that is not a browser's click — the trigger with
61 * the panel's own pointer over a tile (`cursor.ts`) — is the same call a click
62 * makes, and a dead tile is as dead to it.
63 */
64 export type Control = { tile: Tile; act(): void };
65
5666 /**
5767 * What the page has to lend the panel: the element, a way to make a button, and
5868 * what a press means. `press` is the whole of what this module hands back —
@@ -74,7 +84,7 @@ export type Counter = {
7484 * eleven keys, because each tile is printed with the key that presses it.
7585 */
7686 export const HINT =
77 "The number row buys the tile it is printed on · X buys last round's loadout again · " +
87 "Point and click a tile, or press the number printed on it · X buys last round's loadout again · " +
7888 "B or Escape closes. You can only buy in the freeze time.";
7989
8090 /** The control under the grid, and the key it answers to. */
@@ -117,8 +127,9 @@ export const labelOf = (item: BuyItem, team: Team): string =>
117127 * wants to know about it is whether there is any of it: a rebuy with nothing to
118128 * buy back is a dead control rather than a live one that does nothing.
119129 */
120 export function drawBuy(counter: Counter, market: Market, player: Buyer, last: readonly BuyItem[] = []): void {
130 export function drawBuy(counter: Counter, market: Market, player: Buyer, last: readonly BuyItem[] = []): Control[] {
121131 const open = canBuy(market, player);
132 const controls: Control[] = [];
122133 counter.panel.innerHTML =
123134 `<h2>Buy <small>$${player.money}</small></h2><div class="grid"></div>` +
124135 `<div class="row"></div><p class="hint">${HINT}</p>`;
@@ -132,13 +143,28 @@ export function drawBuy(counter: Counter, market: Market, player: Buyer, last: r
132143 tile.type = "button";
133144 tile.textContent = tileLabel(index, label, price);
134145 tile.disabled = price > player.money || !open;
135 tile.addEventListener("click", () => counter.press(item));
146 const act = (): void => counter.press(item);
147 tile.addEventListener("click", act);
136148 grid.appendChild(tile);
149 controls.push({ tile, act });
137150 });
138151 const again = counter.button();
139152 again.type = "button";
140153 again.textContent = REBUY_LABEL;
141154 again.disabled = !open || last.length === 0;
142 again.addEventListener("click", () => counter.again());
155 const rebuyAct = (): void => counter.again();
156 again.addEventListener("click", rebuyAct);
143157 (counter.panel.querySelector(".row") as Shelf).appendChild(again);
158 controls.push({ tile: again, act: rebuyAct });
159 return controls;
160 }
161
162 /**
163 * The control a press at some point lands on, given what is at that point — a
164 * browser's `elementFromPoint` — or null when it is not one of this panel's, or
165 * is one a click would not press either.
166 */
167 export function controlAt(controls: readonly Control[], hit: unknown): Control | null {
168 const found = controls.find((control) => control.tile === hit);
169 return found === undefined || found.tile.disabled ? null : found;
144170 }
src/client/cursor.test.ts added +32
@@ -0,0 +1,32 @@
1 /**
2 * The buy panel's own pointer, as data: where it starts, how a measured turn
3 * becomes pixels, and the edge it stops at. `main.test.ts` holds the wiring —
4 * the mouse moving it, the trigger pressing what is under it.
5 */
6
7 import { expect, test } from "bun:test";
8 import { centre, DEGREES_PER_COUNT, moveCursor, toPixels } from "./cursor.ts";
9
10 const panel = { width: 400, height: 300 };
11
12 test("the pointer starts in the middle of the panel", () => {
13 expect(centre(panel)).toEqual({ x: 200, y: 150 });
14 });
15
16 test("one count of the mouse is one pixel, whatever the sensitivity", () => {
17 for (const sens of [0.5, 2.2, 7]) expect(toPixels(10 * sens * DEGREES_PER_COUNT, sens)).toBeCloseTo(10, 9);
18 expect(toPixels(-3 * DEGREES_PER_COUNT, 1)).toBeCloseTo(-3, 9);
19 // A turn that is not a number, or a sensitivity that is no scale, moves nothing.
20 expect(toPixels(Number.NaN, 2)).toBe(0);
21 expect(toPixels(1, 0)).toBe(0);
22 expect(toPixels(1, -2)).toBe(0);
23 });
24
25 test("the pointer moves, and stops at the panel's edge rather than leaving it", () => {
26 expect(moveCursor({ x: 200, y: 150 }, 50, -30, panel)).toEqual({ x: 250, y: 120 });
27 expect(moveCursor({ x: 390, y: 5 }, 100, -100, panel)).toEqual({ x: 400, y: 0 });
28 expect(moveCursor({ x: 10, y: 290 }, -100, 100, panel)).toEqual({ x: 0, y: 300 });
29 // Nothing that is not a number moves it, and a panel with no size holds it at its corner.
30 expect(moveCursor({ x: 20, y: 30 }, Number.NaN, Infinity, panel)).toEqual({ x: 20, y: 30 });
31 expect(moveCursor({ x: 20, y: 30 }, 5, 5, { width: 0, height: Number.NaN })).toEqual({ x: 0, y: 0 });
32 });
src/client/cursor.ts added +53
@@ -0,0 +1,53 @@
1 /**
2 * The buy panel's own mouse pointer: a point inside the panel that the mouse
3 * moves while the game still holds the pointer lock (#355).
4 *
5 * The panel keeps the lock so a round is not two trips to the desktop (#274),
6 * and a locked pointer is one the browser does not draw and does not click
7 * with. So the game draws its own: while the panel is open the mouse's
8 * movement moves this point instead of turning the view, and the trigger
9 * presses the tile under it. It is plain data — a point, and the box it is
10 * kept inside — and `main.ts` is the only file that knows it is drawn.
11 */
12
13 /** Where the pointer is, in CSS pixels from the panel's top-left corner. */
14 export type Cursor = { x: number; y: number };
15
16 /** The box the pointer is kept inside: the panel's size, in CSS pixels. */
17 export type Area = { width: number; height: number };
18
19 /**
20 * The degrees of turn one count of the mouse is worth at a sensitivity of 1,
21 * which is `createControls`'s default `lookScale` and CS's `m_yaw`. The frame
22 * is handed the turn in degrees; this is how it gets the counts back.
23 */
24 export const DEGREES_PER_COUNT = 0.022;
25
26 /** Where the pointer starts when the panel opens: the middle of it. */
27 export const centre = (area: Area): Cursor => ({ x: area.width / 2, y: area.height / 2 });
28
29 /**
30 * A turn the controls measured, in degrees, as pixels of pointer travel: one
31 * count of the mouse is one pixel, whatever the sensitivity, the way a
32 * desktop pointer does not speed up because the game's aim did.
33 */
34 export function toPixels(degrees: number, sensitivity: number): number {
35 const scale = sensitivity * DEGREES_PER_COUNT;
36 if (!Number.isFinite(degrees) || !(scale > 0)) return 0;
37 return degrees / scale;
38 }
39
40 /** Keep a value inside [0, max], where a max that is not a size is zero. */
41 const within = (value: number, max: number): number =>
42 Math.min(Math.max(value, 0), Number.isFinite(max) && max > 0 ? max : 0);
43
44 /**
45 * The pointer moved by `dx`, `dy` pixels and kept inside the panel: a hand that
46 * keeps going at the edge leaves it at the edge, the way a desktop pointer
47 * stops at the side of the screen. A move that is not a number moves nothing.
48 */
49 export function moveCursor(cursor: Cursor, dx: number, dy: number, area: Area): Cursor {
50 const x = cursor.x + (Number.isFinite(dx) ? dx : 0);
51 const y = cursor.y + (Number.isFinite(dy) ? dy : 0);
52 return { x: within(x, area.width), y: within(y, area.height) };
53 }
src/client/main.test.ts +55
@@ -421,6 +421,61 @@ test("a number key buys the tile it is printed on, and one the wallet cannot rea
421421 expect(world.me.money).toBe(spent);
422422 });
423423
424 test("the mouse buys too: the panel draws its own pointer, the mouse moves it and the trigger presses what is under it", () => {
425 const stage = playing();
426 stage.lock(true);
427 stage.step(FRAME);
428 const world = worldOf(stage);
429 if (world === null) throw new Error("the first frame drew nothing");
430 // The layout a browser has and this page has not: where the panel is, the
431 // pointer's style, and what is drawn at a point — which is the first tile at
432 // exactly one point, so a click anywhere else lands on nothing.
433 const shelf = stage.by("buyshelf");
434 const tiles = () => shelf.querySelector(".grid")?.children ?? [];
435 const pointer = Object.assign(stage.by("buycursor"), { style: { transform: "" } });
436 Object.assign(stage.by("buy"), { getBoundingClientRect: () => ({ left: 10, top: 20, width: 400, height: 300 }) });
437 const asked: [number, number][] = [];
438 Object.assign(stage.document, {
439 elementFromPoint: (x: number, y: number) => (asked.push([x, y]), x === 260 && y === 140 ? tiles()[0] : null),
440 });
441
442 stage.press("KeyB");
443 expect(pointer.hidden).toBe(false);
444 expect(pointer.style.transform).toBe("translate(200.0px, 150.0px)");
445
446 // A trigger over nothing buys nothing, and is not a shot either.
447 const before = world.me.money;
448 stage.scene.fire("mousedown", { button: 0, preventDefault: () => {} });
449 stage.window.fire("mouseup", { button: 0 });
450 expect(asked).toEqual([[210, 170]]);
451 expect(world.me.money).toBe(before);
452
453 // The mouse moves the pointer — one count, one pixel — and not the view.
454 const { yaw, pitch } = world.me;
455 stage.window.fire("mousemove", { movementX: 50, movementY: -30 });
456 stage.step(FRAME);
457 expect(world.me.yaw).toBe(yaw);
458 expect(world.me.pitch).toBe(pitch);
459 expect(pointer.style.transform).toBe("translate(250.0px, 120.0px)");
460
461 // And a click there is the tile's click: the pistol, bought.
462 const [, name] = (tiles()[0]?.textContent ?? "").split(" · ");
463 stage.scene.fire("mousedown", { button: 0, preventDefault: () => {} });
464 stage.window.fire("mouseup", { button: 0 });
465 expect(world.me.money).toBeLessThan(before);
466 expect(stage.by("buysays").textContent).toBe(`Bought ${name}. $${world.me.money} left.`);
467
468 // A hand that keeps going stops at the panel's edge, and closing the panel
469 // gives the mouse back to the view.
470 stage.window.fire("mousemove", { movementX: 3000, movementY: 3000 });
471 stage.step(FRAME);
472 expect(pointer.style.transform).toBe("translate(400.0px, 300.0px)");
473 stage.press("KeyB");
474 stage.window.fire("mousemove", { movementX: 50, movementY: 0 });
475 stage.step(FRAME);
476 expect(world.me.yaw).not.toBe(yaw);
477 });
478
424479 test("X buys last round's loadout again, in order, and stops when the money runs out", () => {
425480 const stage = playing();
426481 stage.lock(true);
src/client/main.ts +64 −4
@@ -98,7 +98,8 @@ import { buildScene, createScene, SKY, type Scene } from "./scene.ts";
9898 import { usePlan, usePolicy } from "./bots.ts";
9999 import { createSpectator, cycle, follow } from "./spectate.ts";
100100 import { WEAPONS } from "./weapons.ts";
101 import { drawBuy, HINT, labelOf, offers } from "./buy.ts";
101 import { controlAt, drawBuy, HINT, labelOf, offers, type Control } from "./buy.ts";
102 import { centre, moveCursor, toPixels, type Cursor } from "./cursor.ts";
102103 import { buy, canBuy, priceOf, type BuyItem } from "./economy.ts";
103104 import { atRound, createLoadout, noteBuy, rebuy, saidRebuy } from "./loadout.ts";
104105 import { createWorld, eyePos, say as showMessage, update, type Player, type World } from "./world.ts";
@@ -172,6 +173,17 @@ export type Panel = Listens & {
172173 appendChild(child: unknown): void;
173174 };
174175
176 /**
177 * The buy panel, which is also the box its own pointer moves in: where it is on
178 * the screen. Optional because a page without layout has no box, and a panel
179 * without one is bought from by the keys alone.
180 */
181 export type Rect = { left: number; top: number; width: number; height: number };
182 export type Shop = Panel & { getBoundingClientRect?(): Rect };
183
184 /** The pointer the buy panel draws inside the lock: moved by its style alone. */
185 export type Pointer = Panel & { style?: { transform: string } };
186
175187 /** A field in the menu: everything a panel is, and what was typed into it. */
176188 export type Field = Panel & { value: string };
177189
@@ -216,6 +228,8 @@ export type PageDocument = Listens & {
216228 getElementById(id: string): unknown;
217229 querySelectorAll(selectors: string): ArrayLike<Button>;
218230 createElement(tag: "button"): Pressable;
231 /** What is drawn at a point on the screen: how the buy panel's pointer finds a tile. */
232 elementFromPoint?(x: number, y: number): unknown;
219233 exitPointerLock(): void;
220234 exitFullscreen?(): unknown;
221235 };
@@ -350,7 +364,7 @@ export function boot(host: Host): Frame {
350364 const fullButton = by<Toggle>("full");
351365 const menu = by("menu");
352366 const pause = by("pause");
353 const buyPanel = by("buy");
367 const buyPanel = by<Shop>("buy");
354368 // The two halves of that panel the page renders and the client fills: the
355369 // element `buy.ts` draws the shelf into — which takes the focus when the
356370 // panel opens, so Tab from there is the first tile — and the line under it,
@@ -359,6 +373,9 @@ export function boot(host: Host): Frame {
359373 // reader hears instead.
360374 const buyShelf = by("buyshelf");
361375 const buySays = by("buysays");
376 // The panel's own pointer (#355): drawn by the page, moved by the mouse while
377 // the lock is kept, and the trigger presses what is under it.
378 const buyCursor = by<Pointer>("buycursor");
362379 const touchPanel = by("touch");
363380 const stats = by("stats");
364381 // The one line about whether what you played is on that board. It is a live
@@ -748,9 +765,11 @@ export function boot(host: Host): Frame {
748765 * read at the press rather than captured, so a tile left over from a match
749766 * that has ended spends nothing.
750767 */
768 /** What the panel drew last, and what pressing each one means. */
769 let buyControls: Control[] = [];
751770 const renderBuy = (): void => {
752771 if (world === null) return;
753 drawBuy(
772 buyControls = drawBuy(
754773 { panel: buyShelf, button: () => doc.createElement("button"), press: pressBuy, again: pressRebuy },
755774 world,
756775 world.me,
@@ -827,8 +846,11 @@ export function boot(host: Host): Frame {
827846 if (world === null) return;
828847 if (open && !canBuy(world, world.me)) return;
829848 show(buyPanel, open);
849 show(buyCursor, false);
830850 if (!open) return;
831851 renderBuy();
852 cursor = centre(shopArea());
853 drawCursor();
832854 sayBuy(`Buy menu: $${world.me.money} to spend. ${HINT}`);
833855 buyShelf.focus();
834856 }
@@ -858,6 +880,41 @@ export function boot(host: Host): Frame {
858880 resumeButton.focus();
859881 }
860882
883 // --- the buy panel's pointer, for a mouse that is still locked to the game
884 /** Where the panel's pointer is, from the panel's top-left corner. */
885 let cursor: Cursor = { x: 0, y: 0 };
886 const shopArea = (): Rect =>
887 buyPanel.getBoundingClientRect?.() ?? { left: 0, top: 0, width: 0, height: 0 };
888
889 /**
890 * Put the pointer where it is. It is shown only while the game holds the
891 * lock — a released mouse has the browser's own, and a thumb needs none —
892 * and only on a page that can say where the panel is.
893 */
894 function drawCursor(): void {
895 const area = shopArea();
896 const shown = !buyPanel.hidden && controls.locked && area.width > 0 && buyCursor.style !== undefined;
897 show(buyCursor, shown);
898 if (shown && buyCursor.style !== undefined)
899 buyCursor.style.transform = `translate(${cursor.x.toFixed(1)}px, ${cursor.y.toFixed(1)}px)`;
900 }
901
902 /** The mouse moved with the panel open: the pointer goes, the view stays. */
903 function steerCursor(dx: number, dy: number): void {
904 if (dx === 0 && dy === 0) return;
905 const sens = controls.sensitivity;
906 cursor = moveCursor(cursor, toPixels(dx, sens), toPixels(dy, sens), shopArea());
907 drawCursor();
908 }
909
910 /** The trigger with the panel open: press the tile under the pointer, if any. */
911 function clickCursor(): void {
912 if (buyCursor.hidden) return;
913 const area = shopArea();
914 const hit = doc.elementFromPoint?.(area.left + cursor.x, area.top + cursor.y);
915 controlAt(buyControls, hit)?.act();
916 }
917
861918 /** What Escape and P both mean: close the buy menu if it is open, else pause. */
862919 function pauseKey(): void {
863920 if (!buyPanel.hidden) return openBuy(false);
@@ -878,6 +935,7 @@ export function boot(host: Host): Frame {
878935 // key that buys the fourth tile is not also a hand reaching for a gun.
879936 const tile = slotIndex(action);
880937 if (tile !== null && !buyPanel.hidden) return buyTile(tile);
938 if (action === "fire" && !buyPanel.hidden) return clickCursor();
881939 // Dead, the trigger is the only thing left to press: it walks the camera
882940 // along your side, and the aim button walks it back. The touch pad's fire
883941 // is the same action, so a phone gets it without a button of its own.
@@ -1503,7 +1561,9 @@ export function boot(host: Host): Frame {
15031561
15041562 const me = world.me;
15051563 const [dx, dy] = controls.takeLook();
1506 if (me.alive && controls.locked) {
1564 // With the shop open the mouse is its pointer, and the view holds still.
1565 if (!buyPanel.hidden && controls.locked) steerCursor(dx, dy);
1566 else if (me.alive && controls.locked) {
15071567 // The engine's degrees, taken through whatever this body is looking out
15081568 // of: scoped, 40° of world on the screen instead of 90° means a hand
15091569 // movement worth `tan(20°) / tan(45°)` of the turn it is worth unscoped,
src/client/stage.test-helper.ts +2 −2
@@ -220,10 +220,10 @@ export type FakeStage = {
220220 * contract between the page and this file, so `src/server.test.ts` checks the
221221 * served page against this same list rather than a second copy of it.
222222 */
223 export const PAGE_IDS = ["weights", "stage", "scene", "hud", "menu", "pause", "summary", "buy", "buyshelf", "buysays", "touch", "stats", "saved", "name", "length", "side", "sens", "map", "mapseed", "matchid", "onid", "onmap", "today", "ontoday", "willplay", "plan", "planalt", "copymap", "play", "room", "joinroom", "makeroom", "roomsaid", "prep", "resume", "quit", "esc", "pauseid", "sumtitle", "sumscore", "sumyou", "sumboard", "sumsaved", "again", "leave", "playing", "mapname", "share", "fps", "full", "support", "supportline", "copysupport"];
223 export const PAGE_IDS = ["weights", "stage", "scene", "hud", "menu", "pause", "summary", "buy", "buycursor", "buyshelf", "buysays", "touch", "stats", "saved", "name", "length", "side", "sens", "map", "mapseed", "matchid", "onid", "onmap", "today", "ontoday", "willplay", "plan", "planalt", "copymap", "play", "room", "joinroom", "makeroom", "roomsaid", "prep", "resume", "quit", "esc", "pauseid", "sumtitle", "sumscore", "sumyou", "sumboard", "sumsaved", "again", "leave", "playing", "mapname", "share", "fps", "full", "support", "supportline", "copysupport"];
224224
225225 /** The ones the server renders with `hidden` on them: the page starts at the menu. */
226 const HIDDEN = ["pause", "summary", "buy", "touch", "full"];
226 const HIDDEN = ["pause", "summary", "buy", "buycursor", "touch", "full"];
227227
228228 /** The `[data-action]` buttons the shell renders for this game. */
229229 export const PAGE_ACTIONS = ["duck", "jump", "use", "reload", "buy", "fire"];
src/pages/game.ts +13 −4
@@ -224,6 +224,12 @@ ul.recent a.map:hover, ul.recent a.map:focus-visible { color:var(--ct); text-dec
224224 else — a focused tile would be a tile Space presses, and Space is jump. */
225225 #buyshelf { display:flex; flex-direction:column; align-items:center; gap:.75rem; width:min(40rem,100%); }
226226 #buyshelf:focus { outline:none; }
227 /* The pointer the panel draws while the game keeps the mouse (#355): an arrow
228 moved by transform alone, so it never lays anything out, and never the thing
229 under itself — the tile below it is what a click lands on. */
230 #buycursor { position:absolute; left:0; top:0; width:0; height:0; pointer-events:none; z-index:1;
231 border-style:solid; border-width:0 0 16px 11px; border-color:transparent transparent transparent #fff;
232 filter:drop-shadow(0 0 1px #000) drop-shadow(0 0 1px #000); }
227233 #buyshelf:focus-visible { outline:2px solid var(--ct); outline-offset:.35rem; border-radius:.45rem; }
228234 `;
229235
@@ -389,8 +395,8 @@ const menu = (visitor: Who, asked: Asked, daily: Daily): string => `
389395 <p class="hint" id="prep">Preparing textures…</p>
390396 <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk ·
391397 mouse to aim and fire · <kbd>R</kbd> reload · <kbd>G</kbd> throw an HE · <kbd>T</kbd> throw a flashbang · <kbd>V</kbd> throw a smoke ·
392 <kbd>E</kbd> plant or defuse · <kbd>B</kbd> buy — the buy menu keeps the mouse on the game, the
393 number row buys the tile it is printed on and <kbd>X</kbd> buys last round's loadout again ·
398 <kbd>E</kbd> plant or defuse · <kbd>B</kbd> buy — the buy menu keeps the mouse on the game and draws its
399 own pointer, so point and click a tile, or press the number printed on it, and <kbd>X</kbd> buys last round's loadout again ·
394400 <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> guns · <kbd>Tab</kbd> scores · <kbd>F</kbd> full screen ·
395401 <kbd>Esc</kbd> or <kbd>P</kbd> pause</p>
396402 <p class="hint">The whole mouse is bound, not just its two big buttons: the wheel jumps
@@ -481,13 +487,16 @@ export const planHtml = (planning: boolean): string =>
481487 *
482488 * The shelf takes the focus when the panel opens and is a group with a name, so
483489 * what opened is announced and Tab from it is the first tile — the panel keeps
484 * the pointer lock now, so there is no cursor to move into it and the focus is
485 * the whole of the way in. The line is a live region, rendered here rather than
490 * the pointer lock now, so the browser's cursor is not there to move into it:
491 * the focus is the way in for a keyboard, and for a mouse the panel draws its
492 * own pointer — `#buycursor`, rendered hidden here, moved and shown by the
493 * client while the lock is held (#355). The line is a live region, rendered here rather than
486494 * written by the client into markup it also replaces: a region a redraw
487495 * destroys is a region a screen reader never hears change, so it is outside
488496 * everything `drawBuy` rewrites and it is server-rendered empty.
489497 */
490498 const BUY = `
499 <div id="buycursor" hidden aria-hidden="true"></div>
491500 <div id="buyshelf" tabindex="-1" role="group" aria-label="Buy"></div>
492501 <p class="hint" id="buysays" role="status" aria-live="polite"></p>
493502 `;
src/server.test.ts +3 −2
@@ -22,7 +22,7 @@ import { PAGE_ACTIONS, PAGE_IDS } from "./client/stage.test-helper.ts";
2222 import { EXAMPLE_ACTIONS, EXAMPLE_IDS } from "./example/stage.test-helper.ts";
2323 // The rooms, for the two doors at the end of this file: what a code is, what
2424 // the hub holds, and the frames that cross a socket once one is open.
25 import { isRoomCode } from "./rooms/code.ts";
25 import { roomCodeFrom } from "./engine/index.ts";
2626 import { CLOSE_TOO_BIG, MESSAGE_MAX, ROOM_CAP } from "./rooms/hub.ts";
2727 import { levelOf, SNAPSHOT_HZ, TEAM_SIZE } from "./rooms/room.ts";
2828 import { applySnapshot, readDown, writeInput, INPUT_BYTES, SNAPSHOT_FRAME, type Hello } from "./rooms/wire.ts";
@@ -1412,7 +1412,8 @@ test("`POST /api/room` answers a code as no-store JSON, on the floor, with a ses
14121412
14131413 // And the answer is a code, a map and a length, and nothing else at all.
14141414 const room = (await response.json()) as Opened;
1415 expect(isRoomCode(room.code)).toBe(true);
1415 // A code already in its own shape: the door hands it back unchanged.
1416 expect(roomCodeFrom(room.code)).toBe(room.code);
14161417 expect(room).toEqual({ code: room.code, map: "de_forge", length: "short" });
14171418 // The code names a room this process is holding, with nobody in it yet.
14181419 expect(hub.find(room.code)?.map).toBe("de_forge");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs