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
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/355-cant-buy-im-trying-to-buy-but-when-i-hit → main 2 commits fork
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 | ||
| 535 | 535 | the pointer still locked, every tile is printed with the key that buys it (the |
| 536 | 536 | number row, <kbd>1</kbd> to <kbd>9</kbd>, then <kbd>0</kbd> and <kbd>-</kbd>, |
| 537 | 537 | 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 — | |
| 541 | 544 | <kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> switch guns — and **a switch costs CS's deploy |
| 542 | 545 | time**: the carry speed changes on the tick you press the key, so tapping |
| 543 | 546 | <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. | ||
| 1088 | 1091 | an item: last round's loadout, bought again. It takes the element to draw |
| 1089 | 1092 | into, the round and the player, and the calls that say what was pressed; it |
| 1090 | 1093 | 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), | |
| 1092 | 1099 | `loadout` (what you bought, and buying it again: a round's purchases in the |
| 1093 | 1100 | order they were made, rolled over on the round, and the rebuy that walks that |
| 1094 | 1101 | 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 | ||
| 49 | 49 | |
| 50 | 50 | test("the entry table is what the app ships today, each with the size it may not pass", () => { |
| 51 | 51 | 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 } }, | |
| 53 | 53 | { name: "range", entry: "src/range/main.ts", ceiling: { raw: 44_000, gzip: 18_200 } }, |
| 54 | 54 | { name: "example", entry: "src/example/main.ts", ceiling: { raw: 26_800, gzip: 11_400 } }, |
| 55 | 55 | ]); |
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
| @@ -558,6 +558,61 @@ test("a number key buys the tile it is printed on, and one the wallet cannot rea | ||
| 558 | 558 | expect(world.me.money).toBe(spent); |
| 559 | 559 | }); |
| 560 | 560 | |
| 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 | ||
| 561 | 616 | test("X buys last round's loadout again, in order, and stops when the money runs out", () => { |
| 562 | 617 | const stage = playing(); |
| 563 | 618 | stage.lock(true); |
src/client/main.ts +64 −4
| @@ -106,7 +106,8 @@ import { callOf, hears, radio } from "./radio.ts"; | ||
| 106 | 106 | import type { Call } from "../plan/call.ts"; |
| 107 | 107 | import { createSpectator, cycle, follow } from "./spectate.ts"; |
| 108 | 108 | 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"; | |
| 110 | 111 | import { buy, canBuy, priceOf, type BuyItem } from "./economy.ts"; |
| 111 | 112 | import { atRound, createLoadout, noteBuy, rebuy, saidRebuy } from "./loadout.ts"; |
| 112 | 113 | import { createWorld, eyePos, say as showMessage, update, type Player, type World } from "./world.ts"; |
| @@ -175,6 +176,17 @@ export type Panel = Listens & { | ||
| 175 | 176 | appendChild(child: unknown): void; |
| 176 | 177 | }; |
| 177 | 178 | |
| 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 | ||
| 178 | 190 | /** A field in the menu: everything a panel is, and what was typed into it. */ |
| 179 | 191 | export type Field = Panel & { value: string }; |
| 180 | 192 | |
| @@ -219,6 +231,8 @@ export type PageDocument = Listens & { | ||
| 219 | 231 | getElementById(id: string): unknown; |
| 220 | 232 | querySelectorAll(selectors: string): ArrayLike<Button>; |
| 221 | 233 | 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; | |
| 222 | 236 | exitPointerLock(): void; |
| 223 | 237 | exitFullscreen?(): unknown; |
| 224 | 238 | }; |
| @@ -353,7 +367,7 @@ export function boot(host: Host): Frame { | ||
| 353 | 367 | const fullButton = by<Toggle>("full"); |
| 354 | 368 | const menu = by("menu"); |
| 355 | 369 | const pause = by("pause"); |
| 356 | const buyPanel = by("buy"); | |
| 370 | const buyPanel = by<Shop>("buy"); | |
| 357 | 371 | // The two halves of that panel the page renders and the client fills: the |
| 358 | 372 | // element `buy.ts` draws the shelf into — which takes the focus when the |
| 359 | 373 | // 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 { | ||
| 362 | 376 | // reader hears instead. |
| 363 | 377 | const buyShelf = by("buyshelf"); |
| 364 | 378 | 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"); | |
| 365 | 382 | const touchPanel = by("touch"); |
| 366 | 383 | const stats = by("stats"); |
| 367 | 384 | // 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 { | ||
| 832 | 849 | * read at the press rather than captured, so a tile left over from a match |
| 833 | 850 | * that has ended spends nothing. |
| 834 | 851 | */ |
| 852 | /** What the panel drew last, and what pressing each one means. */ | |
| 853 | let buyControls: Control[] = []; | |
| 835 | 854 | const renderBuy = (): void => { |
| 836 | 855 | if (world === null) return; |
| 837 | drawBuy( | |
| 856 | buyControls = drawBuy( | |
| 838 | 857 | { panel: buyShelf, button: () => doc.createElement("button"), press: pressBuy, again: pressRebuy }, |
| 839 | 858 | world, |
| 840 | 859 | world.me, |
| @@ -911,8 +930,11 @@ export function boot(host: Host): Frame { | ||
| 911 | 930 | if (world === null) return; |
| 912 | 931 | if (open && !canBuy(world, world.me)) return; |
| 913 | 932 | show(buyPanel, open); |
| 933 | show(buyCursor, false); | |
| 914 | 934 | if (!open) return; |
| 915 | 935 | renderBuy(); |
| 936 | cursor = centre(shopArea()); | |
| 937 | drawCursor(); | |
| 916 | 938 | sayBuy(`Buy menu: $${world.me.money} to spend. ${HINT}`); |
| 917 | 939 | buyShelf.focus(); |
| 918 | 940 | } |
| @@ -942,6 +964,41 @@ export function boot(host: Host): Frame { | ||
| 942 | 964 | resumeButton.focus(); |
| 943 | 965 | } |
| 944 | 966 | |
| 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 | ||
| 945 | 1002 | /** |
| 946 | 1003 | * A call on the radio. A room's round is stepped on the server and its wire |
| 947 | 1004 | * carries no calls yet, so there the key says so rather than pretending; in |
| @@ -977,6 +1034,7 @@ export function boot(host: Host): Frame { | ||
| 977 | 1034 | // key that buys the fourth tile is not also a hand reaching for a gun. |
| 978 | 1035 | const tile = slotIndex(action); |
| 979 | 1036 | if (tile !== null && !buyPanel.hidden) return buyTile(tile); |
| 1037 | if (action === "fire" && !buyPanel.hidden) return clickCursor(); | |
| 980 | 1038 | // Dead, the trigger is the only thing left to press: it walks the camera |
| 981 | 1039 | // along your side, and the aim button walks it back. The touch pad's fire |
| 982 | 1040 | // 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 { | ||
| 1597 | 1655 | |
| 1598 | 1656 | const me = world.me; |
| 1599 | 1657 | 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) { | |
| 1601 | 1661 | // The engine's degrees, taken through whatever this body is looking out |
| 1602 | 1662 | // of: scoped, 40° of world on the screen instead of 90° means a hand |
| 1603 | 1663 | // 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 = { | ||
| 165 | 165 | * contract between the page and this file, so `src/server.test.ts` checks the |
| 166 | 166 | * served page against this same list rather than a second copy of it. |
| 167 | 167 | */ |
| 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"]; | |
| 169 | 169 | |
| 170 | 170 | /** 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"]; | |
| 172 | 172 | |
| 173 | 173 | /** The `[data-action]` buttons the shell renders for this game. */ |
| 174 | 174 | export const PAGE_ACTIONS = ["duck", "jump", "use", "reload", "buy", "fire"]; |
src/games.ts +9 −1
| @@ -127,8 +127,16 @@ export const GAMES: readonly Game[] = [ | ||
| 127 | 127 | * #248 moved `OBS_SIZE` into `src/client/obs-size.ts` to break a load-time |
| 128 | 128 | * cycle, and moved this bundle by the handful the standing fact above says: |
| 129 | 129 | * 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. | |
| 130 | 138 | */ |
| 131 | ceiling: { raw: 140_500, gzip: 54_500 }, | |
| 139 | ceiling: { raw: 147_600, gzip: 57_500 }, | |
| 132 | 140 | assets: [ |
| 133 | 141 | { |
| 134 | 142 | 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 | ||
| 266 | 266 | else — a focused tile would be a tile Space presses, and Space is jump. */ |
| 267 | 267 | #buyshelf { display:flex; flex-direction:column; align-items:center; gap:.75rem; width:min(40rem,100%); } |
| 268 | 268 | #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); } | |
| 269 | 275 | #buyshelf:focus-visible { outline:2px solid var(--ct); outline-offset:.35rem; border-radius:.45rem; } |
| 270 | 276 | `; |
| 271 | 277 | |
| @@ -453,8 +459,8 @@ ${settingsHtml("")} | ||
| 453 | 459 | <p class="hint" id="prep">Preparing textures…</p> |
| 454 | 460 | <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk · |
| 455 | 461 | 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 · | |
| 458 | 464 | <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> guns · <kbd>Tab</kbd> scores · <kbd>F</kbd> full screen · |
| 459 | 465 | <kbd>Esc</kbd> or <kbd>P</kbd> pause</p> |
| 460 | 466 | <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 => { | ||
| 557 | 563 | * |
| 558 | 564 | * The shelf takes the focus when the panel opens and is a group with a name, so |
| 559 | 565 | * 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 | |
| 562 | 570 | * written by the client into markup it also replaces: a region a redraw |
| 563 | 571 | * destroys is a region a screen reader never hears change, so it is outside |
| 564 | 572 | * everything `drawBuy` rewrites and it is server-rendered empty. |
| 565 | 573 | */ |
| 566 | 574 | const BUY = ` |
| 575 | <div id="buycursor" hidden aria-hidden="true"></div> | |
| 567 | 576 | <div id="buyshelf" tabindex="-1" role="group" aria-label="Buy"></div> |
| 568 | 577 | <p class="hint" id="buysays" role="status" aria-live="polite"></p> |
| 569 | 578 | `; |