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… #420

Merged opened this in answer to

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

Merged

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

  • Merged. vesper/355-cant-buy-im-trying-to-buy-but-when-i-hit was merged into main as ccc87de.

Review

Sign in to review this change.
11 files changed +295 −23
README.md +11 −4
@@ -535,9 +535,12 @@ time — **and buying never takes the mouse off the game**: the panel opens with
535535 the pointer still locked, every tile is printed with the key that buys it (the
536536 number row, <kbd>1</kbd> to <kbd>9</kbd>, then <kbd>0</kbd> and <kbd>-</kbd>,
537537 left to right), <kbd>X</kbd> buys last round's loadout again as far as the money
538 goes, and <kbd>B</kbd> or <kbd>Esc</kbd> closes. The tiles are still buttons, so
539 a thumb taps them and a keyboard tabs to them, and with the panel open the
540 trigger and the weapon keys are off the player while your feet still work —
538 goes, and <kbd>B</kbd> or <kbd>Esc</kbd> closes. The mouse buys too: with the
539 lock kept the browser draws no cursor, so the panel draws its own — moving the
540 mouse moves it instead of your view, and the left button presses the tile under
541 it. The tiles are still buttons, so a thumb taps them and a keyboard tabs to
542 them, and with the panel open the trigger and the weapon keys are off the player
543 while your feet still work —
541544 <kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> switch guns — and **a switch costs CS's deploy
542545 time**: the carry speed changes on the tick you press the key, so tapping
543546 <kbd>3</kbd> runs at the knife's 250 u/s straight away, but the weapon you come
@@ -1088,7 +1091,11 @@ never checked itself.
10881091 an item: last round's loadout, bought again. It takes the element to draw
10891092 into, the round and the player, and the calls that say what was pressed; it
10901093 reaches for no document and buys nothing, so `main` spends the money in the
1091 one place that holds a world),
1094 one place that holds a world. It hands back every control it drew with what
1095 pressing it means, so a click by the panel's own pointer is that control's click),
1096 `cursor` (the buy panel's own pointer while the game keeps the mouse locked: a
1097 point kept inside the panel, moved by the turn the controls measured turned
1098 back into counts — one count, one pixel, whatever the sensitivity),
10921099 `loadout` (what you bought, and buying it again: a round's purchases in the
10931100 order they were made, rolled over on the round, and the rebuy that walks that
10941101 list through `economy`'s `buy` — stepping over a gun you survived with and
scripts/build.test.ts +1 −1
@@ -49,7 +49,7 @@ function fixture(): { dir: string; entries: Entry[]; source: (n: string, t: stri
4949
5050 test("the entry table is what the app ships today, each with the size it may not pass", () => {
5151 expect(ENTRIES).toEqual([
52 { name: "app", entry: "src/client/main.ts", ceiling: { raw: 140_500, gzip: 54_500 } },
52 { name: "app", entry: "src/client/main.ts", ceiling: { raw: 147_600, gzip: 57_500 } },
5353 { name: "range", entry: "src/range/main.ts", ceiling: { raw: 44_000, gzip: 18_200 } },
5454 { name: "example", entry: "src/example/main.ts", ceiling: { raw: 26_800, gzip: 11_400 } },
5555 ]);
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
@@ -558,6 +558,61 @@ test("a number key buys the tile it is printed on, and one the wallet cannot rea
558558 expect(world.me.money).toBe(spent);
559559 });
560560
561 test("the mouse buys too: the panel draws its own pointer, the mouse moves it and the trigger presses what is under it", () => {
562 const stage = playing();
563 stage.lock(true);
564 stage.step(FRAME);
565 const world = worldOf(stage);
566 if (world === null) throw new Error("the first frame drew nothing");
567 // The layout a browser has and this page has not: where the panel is, the
568 // pointer's style, and what is drawn at a point — which is the first tile at
569 // exactly one point, so a click anywhere else lands on nothing.
570 const shelf = stage.by("buyshelf");
571 const tiles = () => shelf.querySelector(".grid")?.children ?? [];
572 const pointer = Object.assign(stage.by("buycursor"), { style: { transform: "" } });
573 Object.assign(stage.by("buy"), { getBoundingClientRect: () => ({ left: 10, top: 20, width: 400, height: 300 }) });
574 const asked: [number, number][] = [];
575 Object.assign(stage.document, {
576 elementFromPoint: (x: number, y: number) => (asked.push([x, y]), x === 260 && y === 140 ? tiles()[0] : null),
577 });
578
579 stage.press("KeyB");
580 expect(pointer.hidden).toBe(false);
581 expect(pointer.style.transform).toBe("translate(200.0px, 150.0px)");
582
583 // A trigger over nothing buys nothing, and is not a shot either.
584 const before = world.me.money;
585 stage.scene.fire("mousedown", { button: 0, preventDefault: () => {} });
586 stage.window.fire("mouseup", { button: 0 });
587 expect(asked).toEqual([[210, 170]]);
588 expect(world.me.money).toBe(before);
589
590 // The mouse moves the pointer — one count, one pixel — and not the view.
591 const { yaw, pitch } = world.me;
592 stage.window.fire("mousemove", { movementX: 50, movementY: -30 });
593 stage.step(FRAME);
594 expect(world.me.yaw).toBe(yaw);
595 expect(world.me.pitch).toBe(pitch);
596 expect(pointer.style.transform).toBe("translate(250.0px, 120.0px)");
597
598 // And a click there is the tile's click: the pistol, bought.
599 const [, name] = (tiles()[0]?.textContent ?? "").split(" · ");
600 stage.scene.fire("mousedown", { button: 0, preventDefault: () => {} });
601 stage.window.fire("mouseup", { button: 0 });
602 expect(world.me.money).toBeLessThan(before);
603 expect(stage.by("buysays").textContent).toBe(`Bought ${name}. $${world.me.money} left.`);
604
605 // A hand that keeps going stops at the panel's edge, and closing the panel
606 // gives the mouse back to the view.
607 stage.window.fire("mousemove", { movementX: 3000, movementY: 3000 });
608 stage.step(FRAME);
609 expect(pointer.style.transform).toBe("translate(400.0px, 300.0px)");
610 stage.press("KeyB");
611 stage.window.fire("mousemove", { movementX: 50, movementY: 0 });
612 stage.step(FRAME);
613 expect(world.me.yaw).not.toBe(yaw);
614 });
615
561616 test("X buys last round's loadout again, in order, and stops when the money runs out", () => {
562617 const stage = playing();
563618 stage.lock(true);
src/client/main.ts +64 −4
@@ -106,7 +106,8 @@ import { callOf, hears, radio } from "./radio.ts";
106106 import type { Call } from "../plan/call.ts";
107107 import { createSpectator, cycle, follow } from "./spectate.ts";
108108 import { WEAPONS } from "./weapons.ts";
109 import { drawBuy, HINT, labelOf, offers } from "./buy.ts";
109 import { controlAt, drawBuy, HINT, labelOf, offers, type Control } from "./buy.ts";
110 import { centre, moveCursor, toPixels, type Cursor } from "./cursor.ts";
110111 import { buy, canBuy, priceOf, type BuyItem } from "./economy.ts";
111112 import { atRound, createLoadout, noteBuy, rebuy, saidRebuy } from "./loadout.ts";
112113 import { createWorld, eyePos, say as showMessage, update, type Player, type World } from "./world.ts";
@@ -175,6 +176,17 @@ export type Panel = Listens & {
175176 appendChild(child: unknown): void;
176177 };
177178
179 /**
180 * The buy panel, which is also the box its own pointer moves in: where it is on
181 * the screen. Optional because a page without layout has no box, and a panel
182 * without one is bought from by the keys alone.
183 */
184 export type Rect = { left: number; top: number; width: number; height: number };
185 export type Shop = Panel & { getBoundingClientRect?(): Rect };
186
187 /** The pointer the buy panel draws inside the lock: moved by its style alone. */
188 export type Pointer = Panel & { style?: { transform: string } };
189
178190 /** A field in the menu: everything a panel is, and what was typed into it. */
179191 export type Field = Panel & { value: string };
180192
@@ -219,6 +231,8 @@ export type PageDocument = Listens & {
219231 getElementById(id: string): unknown;
220232 querySelectorAll(selectors: string): ArrayLike<Button>;
221233 createElement(tag: "button"): Pressable;
234 /** What is drawn at a point on the screen: how the buy panel's pointer finds a tile. */
235 elementFromPoint?(x: number, y: number): unknown;
222236 exitPointerLock(): void;
223237 exitFullscreen?(): unknown;
224238 };
@@ -353,7 +367,7 @@ export function boot(host: Host): Frame {
353367 const fullButton = by<Toggle>("full");
354368 const menu = by("menu");
355369 const pause = by("pause");
356 const buyPanel = by("buy");
370 const buyPanel = by<Shop>("buy");
357371 // The two halves of that panel the page renders and the client fills: the
358372 // element `buy.ts` draws the shelf into — which takes the focus when the
359373 // panel opens, so Tab from there is the first tile — and the line under it,
@@ -362,6 +376,9 @@ export function boot(host: Host): Frame {
362376 // reader hears instead.
363377 const buyShelf = by("buyshelf");
364378 const buySays = by("buysays");
379 // The panel's own pointer (#355): drawn by the page, moved by the mouse while
380 // the lock is kept, and the trigger presses what is under it.
381 const buyCursor = by<Pointer>("buycursor");
365382 const touchPanel = by("touch");
366383 const stats = by("stats");
367384 // The one line about whether what you played is on that board. It is a live
@@ -832,9 +849,11 @@ export function boot(host: Host): Frame {
832849 * read at the press rather than captured, so a tile left over from a match
833850 * that has ended spends nothing.
834851 */
852 /** What the panel drew last, and what pressing each one means. */
853 let buyControls: Control[] = [];
835854 const renderBuy = (): void => {
836855 if (world === null) return;
837 drawBuy(
856 buyControls = drawBuy(
838857 { panel: buyShelf, button: () => doc.createElement("button"), press: pressBuy, again: pressRebuy },
839858 world,
840859 world.me,
@@ -911,8 +930,11 @@ export function boot(host: Host): Frame {
911930 if (world === null) return;
912931 if (open && !canBuy(world, world.me)) return;
913932 show(buyPanel, open);
933 show(buyCursor, false);
914934 if (!open) return;
915935 renderBuy();
936 cursor = centre(shopArea());
937 drawCursor();
916938 sayBuy(`Buy menu: $${world.me.money} to spend. ${HINT}`);
917939 buyShelf.focus();
918940 }
@@ -942,6 +964,41 @@ export function boot(host: Host): Frame {
942964 resumeButton.focus();
943965 }
944966
967 // --- the buy panel's pointer, for a mouse that is still locked to the game
968 /** Where the panel's pointer is, from the panel's top-left corner. */
969 let cursor: Cursor = { x: 0, y: 0 };
970 const shopArea = (): Rect =>
971 buyPanel.getBoundingClientRect?.() ?? { left: 0, top: 0, width: 0, height: 0 };
972
973 /**
974 * Put the pointer where it is. It is shown only while the game holds the
975 * lock — a released mouse has the browser's own, and a thumb needs none —
976 * and only on a page that can say where the panel is.
977 */
978 function drawCursor(): void {
979 const area = shopArea();
980 const shown = !buyPanel.hidden && controls.locked && area.width > 0 && buyCursor.style !== undefined;
981 show(buyCursor, shown);
982 if (shown && buyCursor.style !== undefined)
983 buyCursor.style.transform = `translate(${cursor.x.toFixed(1)}px, ${cursor.y.toFixed(1)}px)`;
984 }
985
986 /** The mouse moved with the panel open: the pointer goes, the view stays. */
987 function steerCursor(dx: number, dy: number): void {
988 if (dx === 0 && dy === 0) return;
989 const sens = controls.sensitivity;
990 cursor = moveCursor(cursor, toPixels(dx, sens), toPixels(dy, sens), shopArea());
991 drawCursor();
992 }
993
994 /** The trigger with the panel open: press the tile under the pointer, if any. */
995 function clickCursor(): void {
996 if (buyCursor.hidden) return;
997 const area = shopArea();
998 const hit = doc.elementFromPoint?.(area.left + cursor.x, area.top + cursor.y);
999 controlAt(buyControls, hit)?.act();
1000 }
1001
9451002 /**
9461003 * A call on the radio. A room's round is stepped on the server and its wire
9471004 * carries no calls yet, so there the key says so rather than pretending; in
@@ -977,6 +1034,7 @@ export function boot(host: Host): Frame {
9771034 // key that buys the fourth tile is not also a hand reaching for a gun.
9781035 const tile = slotIndex(action);
9791036 if (tile !== null && !buyPanel.hidden) return buyTile(tile);
1037 if (action === "fire" && !buyPanel.hidden) return clickCursor();
9801038 // Dead, the trigger is the only thing left to press: it walks the camera
9811039 // along your side, and the aim button walks it back. The touch pad's fire
9821040 // is the same action, so a phone gets it without a button of its own.
@@ -1597,7 +1655,9 @@ export function boot(host: Host): Frame {
15971655
15981656 const me = world.me;
15991657 const [dx, dy] = controls.takeLook();
1600 if (me.alive && controls.locked) {
1658 // With the shop open the mouse is its pointer, and the view holds still.
1659 if (!buyPanel.hidden && controls.locked) steerCursor(dx, dy);
1660 else if (me.alive && controls.locked) {
16011661 // The engine's degrees, taken through whatever this body is looking out
16021662 // of: scoped, 40° of world on the screen instead of 90° means a hand
16031663 // movement worth `tan(20°) / tan(45°)` of the turn it is worth unscoped,
src/client/stage.test-helper.ts +2 −2
@@ -165,10 +165,10 @@ export type FakeStage = {
165165 * contract between the page and this file, so `src/server.test.ts` checks the
166166 * served page against this same list rather than a second copy of it.
167167 */
168 export const PAGE_IDS = ["weights", "stage", "scene", "hud", "menu", "pause", "summary", "buy", "buyshelf", "buysays", "touch", "stats", "saved", "name", "length", "side", "sens", "touchsens", "motion", "sound", "map", "mapseed", "matchid", "onid", "onmap", "today", "ontoday", "willplay", "plan", "planalt", "copymap", "play", "room", "roomside", "joinroom", "makeroom", "copyroom", "roomsaid", "prep", "resume", "quit", "psens", "ptouchsens", "pmotion", "psound", "esc", "pauseid", "sumtitle", "sumscore", "sumyou", "sumboard", "sumsaved", "again", "leave", "playing", "mapname", "share", "fps", "full", "support", "supportline", "copysupport"];
168 export const PAGE_IDS = ["weights", "stage", "scene", "hud", "menu", "pause", "summary", "buy", "buycursor", "buyshelf", "buysays", "touch", "stats", "saved", "name", "length", "side", "sens", "touchsens", "motion", "sound", "map", "mapseed", "matchid", "onid", "onmap", "today", "ontoday", "willplay", "plan", "planalt", "copymap", "play", "room", "roomside", "joinroom", "makeroom", "copyroom", "roomsaid", "prep", "resume", "quit", "psens", "ptouchsens", "pmotion", "psound", "esc", "pauseid", "sumtitle", "sumscore", "sumyou", "sumboard", "sumsaved", "again", "leave", "playing", "mapname", "share", "fps", "full", "support", "supportline", "copysupport"];
169169
170170 /** The ones the server renders with `hidden` on them: the page starts at the menu. */
171 const HIDDEN = ["pause", "summary", "buy", "touch", "full"];
171 const HIDDEN = ["pause", "summary", "buy", "buycursor", "touch", "full"];
172172
173173 /** The `[data-action]` buttons the shell renders for this game. */
174174 export const PAGE_ACTIONS = ["duck", "jump", "use", "reload", "buy", "fire"];
src/games.ts +9 −1
@@ -127,8 +127,16 @@ export const GAMES: readonly Game[] = [
127127 * #248 moved `OBS_SIZE` into `src/client/obs-size.ts` to break a load-time
128128 * cycle, and moved this bundle by the handful the standing fact above says:
129129 * 137,564 → 137,568 raw and 53,443 → 53,440 gzipped. No raise.
130 *
131 * Raised by #355, which gives the buy panel a pointer of its own while the
132 * mouse stays locked to the game (`src/client/cursor.ts`, the controls
133 * `drawBuy` hands back, and the wiring in `main.ts`): 1,087 raw and 462
134 * gzipped bytes on a `main` that had 196 gzipped bytes left, so a mouse
135 * that can click a tile cannot land under the old number. 140,568 raw and
136 * 54,766 gzipped, measured by `bun run scripts/build.ts`; the ceiling is
137 * that plus about five per cent.
130138 */
131 ceiling: { raw: 140_500, gzip: 54_500 },
139 ceiling: { raw: 147_600, gzip: 57_500 },
132140 assets: [
133141 {
134142 name: "weights",
src/pages/game.ts +13 −4
@@ -266,6 +266,12 @@ ul.recent a.map:hover, ul.recent a.map:focus-visible { color:var(--ct); text-dec
266266 else — a focused tile would be a tile Space presses, and Space is jump. */
267267 #buyshelf { display:flex; flex-direction:column; align-items:center; gap:.75rem; width:min(40rem,100%); }
268268 #buyshelf:focus { outline:none; }
269 /* The pointer the panel draws while the game keeps the mouse (#355): an arrow
270 moved by transform alone, so it never lays anything out, and never the thing
271 under itself — the tile below it is what a click lands on. */
272 #buycursor { position:absolute; left:0; top:0; width:0; height:0; pointer-events:none; z-index:1;
273 border-style:solid; border-width:0 0 16px 11px; border-color:transparent transparent transparent #fff;
274 filter:drop-shadow(0 0 1px #000) drop-shadow(0 0 1px #000); }
269275 #buyshelf:focus-visible { outline:2px solid var(--ct); outline-offset:.35rem; border-radius:.45rem; }
270276 `;
271277
@@ -453,8 +459,8 @@ ${settingsHtml("")}
453459 <p class="hint" id="prep">Preparing textures…</p>
454460 <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk ·
455461 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 ·
456 <kbd>E</kbd> plant or defuse · <kbd>B</kbd> buy — the buy menu keeps the mouse on the game, the
457 number row buys the tile it is printed on and <kbd>X</kbd> buys last round's loadout again ·
462 <kbd>E</kbd> plant or defuse · <kbd>B</kbd> buy — the buy menu keeps the mouse on the game and draws its
463 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 ·
458464 <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> guns · <kbd>Tab</kbd> scores · <kbd>F</kbd> full screen ·
459465 <kbd>Esc</kbd> or <kbd>P</kbd> pause</p>
460466 <p class="hint">The whole mouse is bound, not just its two big buttons: the wheel jumps
@@ -557,13 +563,16 @@ const settingsHtml = (prefix: "" | "p"): string => {
557563 *
558564 * The shelf takes the focus when the panel opens and is a group with a name, so
559565 * what opened is announced and Tab from it is the first tile — the panel keeps
560 * the pointer lock now, so there is no cursor to move into it and the focus is
561 * the whole of the way in. The line is a live region, rendered here rather than
566 * the pointer lock now, so the browser's cursor is not there to move into it:
567 * the focus is the way in for a keyboard, and for a mouse the panel draws its
568 * own pointer — `#buycursor`, rendered hidden here, moved and shown by the
569 * client while the lock is held (#355). The line is a live region, rendered here rather than
562570 * written by the client into markup it also replaces: a region a redraw
563571 * destroys is a region a screen reader never hears change, so it is outside
564572 * everything `drawBuy` rewrites and it is server-rendered empty.
565573 */
566574 const BUY = `
575 <div id="buycursor" hidden aria-hidden="true"></div>
567576 <div id="buyshelf" tabindex="-1" role="group" aria-label="Buy"></div>
568577 <p class="hint" id="buysays" role="status" aria-live="polite"></p>
569578 `;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs