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
vesper/cs-engine:vesper/355-cant-buy-im-trying-to-buy-but-when-i-hit → main 1 commit fork
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 | ||
| 397 | 397 | the pointer still locked, every tile is printed with the key that buys it (the |
| 398 | 398 | number row, <kbd>1</kbd> to <kbd>9</kbd>, then <kbd>0</kbd> and <kbd>-</kbd>, |
| 399 | 399 | 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 — | |
| 403 | 406 | <kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> switch guns — and **a switch costs CS's deploy |
| 404 | 407 | time**: the carry speed changes on the tick you press the key, so tapping |
| 405 | 408 | <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. | ||
| 867 | 870 | an item: last round's loadout, bought again. It takes the element to draw |
| 868 | 871 | into, the round and the player, and the calls that say what was pressed; it |
| 869 | 872 | 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), | |
| 871 | 878 | `loadout` (what you bought, and buying it again: a round's purchases in the |
| 872 | 879 | order they were made, rolled over on the round, and the rebuy that walks that |
| 873 | 880 | list through `economy`'s `buy` — stepping over a gun you survived with and |
src/client/buy.test.ts +24 −2
| @@ -17,7 +17,7 @@ | ||
| 17 | 17 | import { expect, test } from "bun:test"; |
| 18 | 18 | import { NUMBER_ROW } from "./binding.ts"; |
| 19 | 19 | 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"; | |
| 21 | 21 | import { emptyAmmo, type BuyItem, type Buyer, type Market } from "./economy.ts"; |
| 22 | 22 | import { DE_FORGE } from "./map.ts"; |
| 23 | 23 | 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 | ||
| 228 | 228 | const it = shop(); |
| 229 | 229 | drawBuy(it.counter, freeze, buyer()); |
| 230 | 230 | 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"); | |
| 232 | 233 | expect(HINT).toContain("X"); |
| 233 | 234 | expect(HINT).toContain("B or Escape closes"); |
| 234 | 235 | // 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 | ||
| 237 | 238 | expect(labelOf("helmet", "ct")).toBe("Kevlar + Helmet"); |
| 238 | 239 | expect(labelOf("kit", "ct")).toBe("Defuse kit"); |
| 239 | 240 | }); |
| 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 @@ | ||
| 12 | 12 | * the game: the hand that buys is the hand that is walking out of spawn, and a |
| 13 | 13 | * tile you can only reach by pointing at it is a tile that costs a pointer |
| 14 | 14 | * 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. | |
| 16 | 18 | * |
| 17 | 19 | * It is not a browser and it is not the frame. It takes the element to draw |
| 18 | 20 | * into, the two things a price is worked out from — the round and the player — |
| @@ -53,6 +55,14 @@ export type Shelf = { | ||
| 53 | 55 | /** One tile in the grid: a button, its label, whether it can be pressed. */ |
| 54 | 56 | export type Tile = Listens & { type: string; textContent: string | null; disabled: boolean }; |
| 55 | 57 | |
| 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 | ||
| 56 | 66 | /** |
| 57 | 67 | * What the page has to lend the panel: the element, a way to make a button, and |
| 58 | 68 | * what a press means. `press` is the whole of what this module hands back — |
| @@ -74,7 +84,7 @@ export type Counter = { | ||
| 74 | 84 | * eleven keys, because each tile is printed with the key that presses it. |
| 75 | 85 | */ |
| 76 | 86 | 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 · " + | |
| 78 | 88 | "B or Escape closes. You can only buy in the freeze time."; |
| 79 | 89 | |
| 80 | 90 | /** The control under the grid, and the key it answers to. */ |
| @@ -117,8 +127,9 @@ export const labelOf = (item: BuyItem, team: Team): string => | ||
| 117 | 127 | * wants to know about it is whether there is any of it: a rebuy with nothing to |
| 118 | 128 | * buy back is a dead control rather than a live one that does nothing. |
| 119 | 129 | */ |
| 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[] { | |
| 121 | 131 | const open = canBuy(market, player); |
| 132 | const controls: Control[] = []; | |
| 122 | 133 | counter.panel.innerHTML = |
| 123 | 134 | `<h2>Buy <small>$${player.money}</small></h2><div class="grid"></div>` + |
| 124 | 135 | `<div class="row"></div><p class="hint">${HINT}</p>`; |
| @@ -132,13 +143,28 @@ export function drawBuy(counter: Counter, market: Market, player: Buyer, last: r | ||
| 132 | 143 | tile.type = "button"; |
| 133 | 144 | tile.textContent = tileLabel(index, label, price); |
| 134 | 145 | 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); | |
| 136 | 148 | grid.appendChild(tile); |
| 149 | controls.push({ tile, act }); | |
| 137 | 150 | }); |
| 138 | 151 | const again = counter.button(); |
| 139 | 152 | again.type = "button"; |
| 140 | 153 | again.textContent = REBUY_LABEL; |
| 141 | 154 | again.disabled = !open || last.length === 0; |
| 142 | again.addEventListener("click", () => counter.again()); | |
| 155 | const rebuyAct = (): void => counter.again(); | |
| 156 | again.addEventListener("click", rebuyAct); | |
| 143 | 157 | (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; | |
| 144 | 170 | } |
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 | ||
| 421 | 421 | expect(world.me.money).toBe(spent); |
| 422 | 422 | }); |
| 423 | 423 | |
| 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 | ||
| 424 | 479 | test("X buys last round's loadout again, in order, and stops when the money runs out", () => { |
| 425 | 480 | const stage = playing(); |
| 426 | 481 | stage.lock(true); |
src/client/main.ts +64 −4
| @@ -98,7 +98,8 @@ import { buildScene, createScene, SKY, type Scene } from "./scene.ts"; | ||
| 98 | 98 | import { usePlan, usePolicy } from "./bots.ts"; |
| 99 | 99 | import { createSpectator, cycle, follow } from "./spectate.ts"; |
| 100 | 100 | 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"; | |
| 102 | 103 | import { buy, canBuy, priceOf, type BuyItem } from "./economy.ts"; |
| 103 | 104 | import { atRound, createLoadout, noteBuy, rebuy, saidRebuy } from "./loadout.ts"; |
| 104 | 105 | import { createWorld, eyePos, say as showMessage, update, type Player, type World } from "./world.ts"; |
| @@ -172,6 +173,17 @@ export type Panel = Listens & { | ||
| 172 | 173 | appendChild(child: unknown): void; |
| 173 | 174 | }; |
| 174 | 175 | |
| 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 | ||
| 175 | 187 | /** A field in the menu: everything a panel is, and what was typed into it. */ |
| 176 | 188 | export type Field = Panel & { value: string }; |
| 177 | 189 | |
| @@ -216,6 +228,8 @@ export type PageDocument = Listens & { | ||
| 216 | 228 | getElementById(id: string): unknown; |
| 217 | 229 | querySelectorAll(selectors: string): ArrayLike<Button>; |
| 218 | 230 | 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; | |
| 219 | 233 | exitPointerLock(): void; |
| 220 | 234 | exitFullscreen?(): unknown; |
| 221 | 235 | }; |
| @@ -350,7 +364,7 @@ export function boot(host: Host): Frame { | ||
| 350 | 364 | const fullButton = by<Toggle>("full"); |
| 351 | 365 | const menu = by("menu"); |
| 352 | 366 | const pause = by("pause"); |
| 353 | const buyPanel = by("buy"); | |
| 367 | const buyPanel = by<Shop>("buy"); | |
| 354 | 368 | // The two halves of that panel the page renders and the client fills: the |
| 355 | 369 | // element `buy.ts` draws the shelf into — which takes the focus when the |
| 356 | 370 | // 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 { | ||
| 359 | 373 | // reader hears instead. |
| 360 | 374 | const buyShelf = by("buyshelf"); |
| 361 | 375 | 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"); | |
| 362 | 379 | const touchPanel = by("touch"); |
| 363 | 380 | const stats = by("stats"); |
| 364 | 381 | // 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 { | ||
| 748 | 765 | * read at the press rather than captured, so a tile left over from a match |
| 749 | 766 | * that has ended spends nothing. |
| 750 | 767 | */ |
| 768 | /** What the panel drew last, and what pressing each one means. */ | |
| 769 | let buyControls: Control[] = []; | |
| 751 | 770 | const renderBuy = (): void => { |
| 752 | 771 | if (world === null) return; |
| 753 | drawBuy( | |
| 772 | buyControls = drawBuy( | |
| 754 | 773 | { panel: buyShelf, button: () => doc.createElement("button"), press: pressBuy, again: pressRebuy }, |
| 755 | 774 | world, |
| 756 | 775 | world.me, |
| @@ -827,8 +846,11 @@ export function boot(host: Host): Frame { | ||
| 827 | 846 | if (world === null) return; |
| 828 | 847 | if (open && !canBuy(world, world.me)) return; |
| 829 | 848 | show(buyPanel, open); |
| 849 | show(buyCursor, false); | |
| 830 | 850 | if (!open) return; |
| 831 | 851 | renderBuy(); |
| 852 | cursor = centre(shopArea()); | |
| 853 | drawCursor(); | |
| 832 | 854 | sayBuy(`Buy menu: $${world.me.money} to spend. ${HINT}`); |
| 833 | 855 | buyShelf.focus(); |
| 834 | 856 | } |
| @@ -858,6 +880,41 @@ export function boot(host: Host): Frame { | ||
| 858 | 880 | resumeButton.focus(); |
| 859 | 881 | } |
| 860 | 882 | |
| 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 | ||
| 861 | 918 | /** What Escape and P both mean: close the buy menu if it is open, else pause. */ |
| 862 | 919 | function pauseKey(): void { |
| 863 | 920 | if (!buyPanel.hidden) return openBuy(false); |
| @@ -878,6 +935,7 @@ export function boot(host: Host): Frame { | ||
| 878 | 935 | // key that buys the fourth tile is not also a hand reaching for a gun. |
| 879 | 936 | const tile = slotIndex(action); |
| 880 | 937 | if (tile !== null && !buyPanel.hidden) return buyTile(tile); |
| 938 | if (action === "fire" && !buyPanel.hidden) return clickCursor(); | |
| 881 | 939 | // Dead, the trigger is the only thing left to press: it walks the camera |
| 882 | 940 | // along your side, and the aim button walks it back. The touch pad's fire |
| 883 | 941 | // 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 { | ||
| 1503 | 1561 | |
| 1504 | 1562 | const me = world.me; |
| 1505 | 1563 | 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) { | |
| 1507 | 1567 | // The engine's degrees, taken through whatever this body is looking out |
| 1508 | 1568 | // of: scoped, 40° of world on the screen instead of 90° means a hand |
| 1509 | 1569 | // 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 = { | ||
| 220 | 220 | * contract between the page and this file, so `src/server.test.ts` checks the |
| 221 | 221 | * served page against this same list rather than a second copy of it. |
| 222 | 222 | */ |
| 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"]; | |
| 224 | 224 | |
| 225 | 225 | /** 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"]; | |
| 227 | 227 | |
| 228 | 228 | /** The `[data-action]` buttons the shell renders for this game. */ |
| 229 | 229 | 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 | ||
| 224 | 224 | else — a focused tile would be a tile Space presses, and Space is jump. */ |
| 225 | 225 | #buyshelf { display:flex; flex-direction:column; align-items:center; gap:.75rem; width:min(40rem,100%); } |
| 226 | 226 | #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); } | |
| 227 | 233 | #buyshelf:focus-visible { outline:2px solid var(--ct); outline-offset:.35rem; border-radius:.45rem; } |
| 228 | 234 | `; |
| 229 | 235 | |
| @@ -389,8 +395,8 @@ const menu = (visitor: Who, asked: Asked, daily: Daily): string => ` | ||
| 389 | 395 | <p class="hint" id="prep">Preparing textures…</p> |
| 390 | 396 | <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk · |
| 391 | 397 | 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 · | |
| 394 | 400 | <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> guns · <kbd>Tab</kbd> scores · <kbd>F</kbd> full screen · |
| 395 | 401 | <kbd>Esc</kbd> or <kbd>P</kbd> pause</p> |
| 396 | 402 | <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 => | ||
| 481 | 487 | * |
| 482 | 488 | * The shelf takes the focus when the panel opens and is a group with a name, so |
| 483 | 489 | * 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 | |
| 486 | 494 | * written by the client into markup it also replaces: a region a redraw |
| 487 | 495 | * destroys is a region a screen reader never hears change, so it is outside |
| 488 | 496 | * everything `drawBuy` rewrites and it is server-rendered empty. |
| 489 | 497 | */ |
| 490 | 498 | const BUY = ` |
| 499 | <div id="buycursor" hidden aria-hidden="true"></div> | |
| 491 | 500 | <div id="buyshelf" tabindex="-1" role="group" aria-label="Buy"></div> |
| 492 | 501 | <p class="hint" id="buysays" role="status" aria-live="polite"></p> |
| 493 | 502 | `; |
src/server.test.ts +3 −2
| @@ -22,7 +22,7 @@ import { PAGE_ACTIONS, PAGE_IDS } from "./client/stage.test-helper.ts"; | ||
| 22 | 22 | import { EXAMPLE_ACTIONS, EXAMPLE_IDS } from "./example/stage.test-helper.ts"; |
| 23 | 23 | // The rooms, for the two doors at the end of this file: what a code is, what |
| 24 | 24 | // 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"; | |
| 26 | 26 | import { CLOSE_TOO_BIG, MESSAGE_MAX, ROOM_CAP } from "./rooms/hub.ts"; |
| 27 | 27 | import { levelOf, SNAPSHOT_HZ, TEAM_SIZE } from "./rooms/room.ts"; |
| 28 | 28 | 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 | ||
| 1412 | 1412 | |
| 1413 | 1413 | // And the answer is a code, a map and a length, and nothing else at all. |
| 1414 | 1414 | 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); | |
| 1416 | 1417 | expect(room).toEqual({ code: room.code, map: "de_forge", length: "short" }); |
| 1417 | 1418 | // The code names a room this process is holding, with nobody in it yet. |
| 1418 | 1419 | expect(hub.find(room.code)?.map).toBe("de_forge"); |