Split The code is public; the app answers only to the crew.
Bind your own keys: WASD is not everyone's WASD #101
Check · Review · Land · Live done — Landed and live in production.
scout/cs:scout/69-bind-your-own-keys-wasd-is-not-everyone → main 3 commits fork 4 threads · 4 unresolved
README.md +17
| @@ -137,6 +137,23 @@ the scoreboard, <kbd>Esc</kbd> pauses the match on a desktop and `menu` on the | ||
| 137 | 137 | pad does it on a phone. Pausing also opens mute, volume and |
| 138 | 138 | look sensitivity, which take effect at once and are remembered for next time. |
| 139 | 139 | |
| 140 | Every one of those keys is a default, not a floor: **WASD is not everyone's | |
| 141 | WASD** — on an AZERTY board, a Dvorak layout or just a different habit, the | |
| 142 | letters printed on the physical keys are not where this game's defaults | |
| 143 | expect them. The keys are yours to rebind. A collapsed **Controls** disclosure | |
| 144 | sits in the menu and again in the pause overlay (the same 16 actions, always | |
| 145 | in sync) — closed by default so it never pushes anything else out of view; | |
| 146 | opening it is a plain `<details>`, reachable and toggled with a keyboard on | |
| 147 | its own. Click a button to see its action's current key, press it and then press the | |
| 148 | key you want instead, and it takes hold immediately, mid-match included. | |
| 149 | Escape cancels a capture rather than pausing the match. A key already bound | |
| 150 | to something else is refused with a line saying what it is bound to instead — | |
| 151 | nothing is ever left half-bound. **Reset to defaults** puts the whole map | |
| 152 | back. The arrow keys and <kbd>C</kbd> are a fixed movement and crouch | |
| 153 | fallback that no rebind can take over, so a mis-click here is never a match | |
| 154 | you cannot move in. Rebinding is keyboard only — the touch pad's layout does | |
| 155 | not change. | |
| 156 | ||
| 140 | 157 | The menu's **Difficulty** select picks Easy, Normal or Hard before a match |
| 141 | 158 | starts — each one a band of the bots' skill rather than one number, so a |
| 142 | 159 | side still runs from fodder to uncomfortable at every setting, just a |
src/client/bind.test.ts added +48
| @@ -0,0 +1,48 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { decideBind, labelForCode } from "./bind.ts"; | |
| 3 | import { DEFAULT_BINDINGS } from "./input.ts"; | |
| 4 | ||
| 5 | test("a fresh code nothing else holds is accepted, and only that action moves", () => { | |
| 6 | const outcome = decideBind("forward", "KeyZ", DEFAULT_BINDINGS); | |
| 7 | expect(outcome).toEqual({ ok: true, bindings: { ...DEFAULT_BINDINGS, forward: "KeyZ" } }); | |
| 8 | }); | |
| 9 | ||
| 10 | test("a reserved code — the movement and crouch fallback — is refused, and says why", () => { | |
| 11 | for (const code of ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "KeyC"]) { | |
| 12 | const outcome = decideBind("jump", code, DEFAULT_BINDINGS); | |
| 13 | expect(outcome.ok).toBe(false); | |
| 14 | expect(outcome).toMatchObject({ reason: expect.stringContaining("reserved") }); | |
| 15 | } | |
| 16 | }); | |
| 17 | ||
| 18 | test("a code another action already holds is refused, naming that action", () => { | |
| 19 | const outcome = decideBind("jump", DEFAULT_BINDINGS.forward, DEFAULT_BINDINGS); | |
| 20 | expect(outcome.ok).toBe(false); | |
| 21 | expect(outcome).toMatchObject({ reason: expect.stringContaining("Move forward") }); | |
| 22 | }); | |
| 23 | ||
| 24 | test("rebinding an action to the code it already has is a no-op success, not a self-conflict", () => { | |
| 25 | const outcome = decideBind("forward", DEFAULT_BINDINGS.forward, DEFAULT_BINDINGS); | |
| 26 | expect(outcome).toEqual({ ok: true, bindings: DEFAULT_BINDINGS }); | |
| 27 | }); | |
| 28 | ||
| 29 | test("a refusal never changes the map: the caller still holds the bindings it started with", () => { | |
| 30 | const outcome = decideBind("jump", "KeyC", DEFAULT_BINDINGS); | |
| 31 | expect(outcome.ok).toBe(false); | |
| 32 | // decideBind returns no bindings at all on refusal — there is nothing for | |
| 33 | // a caller to accidentally apply. | |
| 34 | expect((outcome as { bindings?: unknown }).bindings).toBeUndefined(); | |
| 35 | }); | |
| 36 | ||
| 37 | test("labelForCode reads a code the way a player would say it", () => { | |
| 38 | expect(labelForCode("KeyW")).toBe("W"); | |
| 39 | expect(labelForCode("Digit1")).toBe("1"); | |
| 40 | expect(labelForCode("Space")).toBe("Space"); | |
| 41 | expect(labelForCode("ControlLeft")).toBe("Left Ctrl"); | |
| 42 | expect(labelForCode("ArrowUp")).toBe("Arrow Up"); | |
| 43 | expect(labelForCode("Numpad5")).toBe("Numpad 5"); | |
| 44 | }); | |
| 45 | ||
| 46 | test("labelForCode never throws on a code it does not recognise — it just shows the code", () => { | |
| 47 | expect(labelForCode("IntlBackslash")).toBe("IntlBackslash"); | |
| 48 | }); |
src/client/bind.ts added +55
| @@ -0,0 +1,55 @@ | ||
| 1 | /** | |
| 2 | * The rebind panel's rules: what a captured key does to a binding map, and | |
| 3 | * what a key or an action is called on the screen. Pure data in, plain data | |
| 4 | * out — nothing here touches the DOM, so main.ts is free to paint it however | |
| 5 | * a click or a screen reader needs, the way buy.ts already keeps the buy | |
| 6 | * panel's rules out of main.ts. | |
| 7 | */ | |
| 8 | ||
| 9 | import { ACTIONS, RESERVED_CODES, type Action } from "./input.ts"; | |
| 10 | ||
| 11 | export const ACTION_LABELS: Record<Action, string> = { | |
| 12 | forward: "Move forward", back: "Move back", left: "Strafe left", right: "Strafe right", | |
| 13 | jump: "Jump", duck: "Crouch", walk: "Walk", | |
| 14 | reload: "Reload", use: "Use / plant / defuse", buy: "Buy menu", | |
| 15 | scoreboard: "Scoreboard", scope: "Scope", | |
| 16 | slot1: "Slot 1 — rifle", slot2: "Slot 2 — pistol", slot3: "Slot 3 — knife", slot4: "Slot 4 — grenade", | |
| 17 | }; | |
| 18 | ||
| 19 | const CODE_LABELS: Record<string, string> = { | |
| 20 | Space: "Space", Tab: "Tab", Escape: "Escape", CapsLock: "Caps Lock", Enter: "Enter", Backspace: "Backspace", | |
| 21 | ControlLeft: "Left Ctrl", ControlRight: "Right Ctrl", ShiftLeft: "Left Shift", ShiftRight: "Right Shift", | |
| 22 | AltLeft: "Left Alt", AltRight: "Right Alt", Backquote: "`", Minus: "-", Equal: "=", | |
| 23 | BracketLeft: "[", BracketRight: "]", Semicolon: ";", Quote: "'", Comma: ",", Period: ".", | |
| 24 | Slash: "/", Backslash: "\\", | |
| 25 | }; | |
| 26 | ||
| 27 | /** A key code, as a player reads it — not as the DOM names it. */ | |
| 28 | export function labelForCode(code: string): string { | |
| 29 | if (code.startsWith("Key")) return code.slice(3); | |
| 30 | if (code.startsWith("Digit")) return code.slice(5); | |
| 31 | if (code.startsWith("Numpad")) return `Numpad ${code.slice(6)}`; | |
| 32 | if (code.startsWith("Arrow")) return `Arrow ${code.slice(5)}`; | |
| 33 | return CODE_LABELS[code] ?? code; | |
| 34 | } | |
| 35 | ||
| 36 | export type BindOutcome = | |
| 37 | | { ok: true; bindings: Record<Action, string> } | |
| 38 | | { ok: false; reason: string }; | |
| 39 | ||
| 40 | /** | |
| 41 | * What a captured key does to `action`'s binding: reserved codes (the | |
| 42 | * movement/crouch fallback) are refused outright, a code another action | |
| 43 | * already holds is refused with which one, and otherwise the map gets the | |
| 44 | * one change asked for and nothing else moves. | |
| 45 | */ | |
| 46 | export function decideBind(action: Action, code: string, bindings: Record<Action, string>): BindOutcome { | |
| 47 | if (RESERVED_CODES.has(code)) { | |
| 48 | return { ok: false, reason: `${labelForCode(code)} is reserved for movement and can't be bound.` }; | |
| 49 | } | |
| 50 | const takenBy = ACTIONS.find((other) => other !== action && bindings[other] === code); | |
| 51 | if (takenBy !== undefined) { | |
| 52 | return { ok: false, reason: `${labelForCode(code)} is already bound to ${ACTION_LABELS[takenBy]}.` }; | |
| 53 | } | |
| 54 | return { ok: true, bindings: { ...bindings, [action]: code } }; | |
| 55 | } |
src/client/input.test.ts +70 −3
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { afterEach, expect, test } from "bun:test"; |
| 9 | import { attachButtons, createControls, type Controls } from "./input.ts"; | |
| 9 | import { attachButtons, createControls, DEFAULT_BINDINGS, type Controls } from "./input.ts"; | |
| 10 | 10 | import { downAt } from "./press.ts"; |
| 11 | 11 | |
| 12 | 12 | type Listener = (event: unknown) => void; |
| @@ -47,11 +47,11 @@ afterEach(() => { | ||
| 47 | 47 | }); |
| 48 | 48 | |
| 49 | 49 | /** A canvas 1000 wide: the stick is x < 380, the look and the trigger are past it. */ |
| 50 | function harness(): { canvas: Canvas; controls: Controls } { | |
| 50 | function harness(bindings = DEFAULT_BINDINGS): { canvas: Canvas; controls: Controls } { | |
| 51 | 51 | const canvas = target({ clientWidth: 1000, clientHeight: 600 }); |
| 52 | 52 | globals.window = target(); |
| 53 | 53 | globals.document = target({ pointerLockElement: null }); |
| 54 | const controls = createControls(canvas as unknown as HTMLCanvasElement, 2.2); | |
| 54 | const controls = createControls(canvas as unknown as HTMLCanvasElement, 2.2, bindings); | |
| 55 | 55 | return { canvas, controls }; |
| 56 | 56 | } |
| 57 | 57 | |
| @@ -242,3 +242,70 @@ test("Q scopes from the keyboard the same way", () => { | ||
| 242 | 242 | window.fire("keyup", { code: "KeyQ" }); |
| 243 | 243 | expect(controls.inputAt(controls.take(1), 0).scope).toBe(false); |
| 244 | 244 | }); |
| 245 | ||
| 246 | // --- bindings: a non-default map moves the game, not the printed letters | |
| 247 | ||
| 248 | test("a non-default map moves on the bound key and does nothing on KeyW", () => { | |
| 249 | const { controls } = harness({ ...DEFAULT_BINDINGS, forward: "KeyZ" }); | |
| 250 | const window = globals.window as Canvas; | |
| 251 | ||
| 252 | window.fire("keydown", { code: "KeyW" }); | |
| 253 | expect(controls.inputAt(controls.take(1), 0).forward).toBe(0); | |
| 254 | ||
| 255 | window.fire("keydown", { code: "KeyZ" }); | |
| 256 | expect(controls.inputAt(controls.take(1), 0).forward).toBe(1); | |
| 257 | }); | |
| 258 | ||
| 259 | test("a rebind takes effect on the very next key event, with no detach or re-attach", () => { | |
| 260 | const { controls } = harness(); | |
| 261 | const window = globals.window as Canvas; | |
| 262 | ||
| 263 | window.fire("keydown", { code: "KeyW" }); | |
| 264 | expect(controls.inputAt(controls.take(1), 0).forward).toBe(1); | |
| 265 | window.fire("keyup", { code: "KeyW" }); | |
| 266 | ||
| 267 | controls.setBindings({ ...DEFAULT_BINDINGS, forward: "KeyZ" }); | |
| 268 | ||
| 269 | window.fire("keydown", { code: "KeyW" }); | |
| 270 | expect(controls.inputAt(controls.take(1), 0).forward).toBe(0); | |
| 271 | window.fire("keydown", { code: "KeyZ" }); | |
| 272 | expect(controls.inputAt(controls.take(1), 0).forward).toBe(1); | |
| 273 | }); | |
| 274 | ||
| 275 | test("the arrow keys and C still move and crouch whatever forward is bound to", () => { | |
| 276 | const { controls } = harness({ ...DEFAULT_BINDINGS, forward: "KeyZ", duck: "KeyU" }); | |
| 277 | const window = globals.window as Canvas; | |
| 278 | ||
| 279 | window.fire("keydown", { code: "ArrowUp" }); | |
| 280 | expect(controls.inputAt(controls.take(1), 0).forward).toBe(1); | |
| 281 | window.fire("keydown", { code: "KeyC" }); | |
| 282 | expect(controls.inputAt(controls.take(1), 0).duck).toBe(true); | |
| 283 | }); | |
| 284 | ||
| 285 | test("under pointer lock, an unbound key is still kept off the browser — Tab cannot walk focus off the canvas", () => { | |
| 286 | const { controls } = harness(); | |
| 287 | const window = globals.window as Canvas; | |
| 288 | let prevented = 0; | |
| 289 | const press = (code: string): void => window.fire("keydown", { code, preventDefault: () => { prevented += 1; } }); | |
| 290 | ||
| 291 | press("Backquote"); // unbound, and not locked yet | |
| 292 | expect(prevented).toBe(0); | |
| 293 | ||
| 294 | controls.locked = true; | |
| 295 | press("Backquote"); // unbound, but the lock claims every key | |
| 296 | expect(prevented).toBe(1); | |
| 297 | }); | |
| 298 | ||
| 299 | test("preventDefault follows the action, not the code: a jump rebound off Space no longer eats it", () => { | |
| 300 | const { controls } = harness({ ...DEFAULT_BINDINGS, jump: "KeyJ" }); | |
| 301 | const window = globals.window as Canvas; | |
| 302 | let prevented = 0; | |
| 303 | ||
| 304 | window.fire("keydown", { code: "Space", preventDefault: () => { prevented += 1; } }); | |
| 305 | expect(prevented).toBe(0); | |
| 306 | expect(controls.inputAt(controls.take(1), 0).jump).toBe(false); | |
| 307 | ||
| 308 | window.fire("keydown", { code: "KeyJ", preventDefault: () => { prevented += 1; } }); | |
| 309 | expect(prevented).toBe(1); | |
| 310 | expect(controls.inputAt(controls.take(1), 0).jump).toBe(true); | |
| 311 | }); |
src/client/input.ts +56 −10
| @@ -31,6 +31,8 @@ export type Controls = { | ||
| 31 | 31 | sensitivity: number; |
| 32 | 32 | locked: boolean; |
| 33 | 33 | setAction(action: string, down: boolean): void; |
| 34 | /** A rebind takes hold on the very next key event — no detach, no reload. */ | |
| 35 | setBindings(bindings: Record<Action, string>): void; | |
| 34 | 36 | /** |
| 35 | 37 | * Forget every intent not yet taken — held controls, latched presses and the |
| 36 | 38 | * look that has piled up. What the focus, the pointer lock and the pause |
| @@ -43,18 +45,52 @@ export type Controls = { | ||
| 43 | 45 | detach(): void; |
| 44 | 46 | }; |
| 45 | 47 | |
| 46 | const KEY_ACTIONS: Record<string, string> = { | |
| 47 | KeyW: "forward", KeyS: "back", KeyA: "left", KeyD: "right", | |
| 48 | ArrowUp: "forward", ArrowDown: "back", ArrowLeft: "left", ArrowRight: "right", | |
| 49 | Space: "jump", ControlLeft: "duck", KeyC: "duck", ShiftLeft: "walk", | |
| 50 | KeyR: "reload", KeyE: "use", KeyB: "buy", Tab: "scoreboard", KeyQ: "scope", | |
| 51 | Digit1: "slot1", Digit2: "slot2", Digit3: "slot3", Digit4: "slot4", | |
| 48 | /** | |
| 49 | * Every action a key can drive, and precisely today's map — the default a | |
| 50 | * corrupt or missing setting falls back to. `settings.ts` validates a stored | |
| 51 | * map against this same list; nothing else should invent an action name. | |
| 52 | */ | |
| 53 | export const ACTIONS = [ | |
| 54 | "forward", "back", "left", "right", "jump", "duck", "walk", | |
| 55 | "reload", "use", "buy", "scoreboard", "scope", | |
| 56 | "slot1", "slot2", "slot3", "slot4", | |
| 57 | ] as const; | |
| 58 | export type Action = typeof ACTIONS[number]; | |
| 59 | ||
| 60 | export const DEFAULT_BINDINGS: Record<Action, string> = { | |
| 61 | forward: "KeyW", back: "KeyS", left: "KeyA", right: "KeyD", | |
| 62 | jump: "Space", duck: "ControlLeft", walk: "ShiftLeft", | |
| 63 | reload: "KeyR", use: "KeyE", buy: "KeyB", scoreboard: "Tab", scope: "KeyQ", | |
| 64 | slot1: "Digit1", slot2: "Digit2", slot3: "Digit3", slot4: "Digit4", | |
| 52 | 65 | }; |
| 53 | 66 | |
| 54 | export function createControls(canvas: HTMLCanvasElement, sensitivity = 2.2): Controls { | |
| 67 | /** | |
| 68 | * Codes no binding can claim: the arrow keys and `C` are a fixed movement and | |
| 69 | * crouch fallback, wired below regardless of what a player has bound, so a | |
| 70 | * rebind gone wrong is never a player who cannot move. | |
| 71 | */ | |
| 72 | export const RESERVED_CODES: ReadonlySet<string> = | |
| 73 | new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "KeyC"]); | |
| 74 | ||
| 75 | /** Code to action, the fixed fallback last so nothing bound can ever shadow it. */ | |
| 76 | function reverseBindings(bindings: Record<Action, string>): Record<string, Action | "duck"> { | |
| 77 | const map: Record<string, Action | "duck"> = {}; | |
| 78 | for (const action of ACTIONS) map[bindings[action]] = action; | |
| 79 | map.ArrowUp = "forward"; | |
| 80 | map.ArrowDown = "back"; | |
| 81 | map.ArrowLeft = "left"; | |
| 82 | map.ArrowRight = "right"; | |
| 83 | map.KeyC = "duck"; | |
| 84 | return map; | |
| 85 | } | |
| 86 | ||
| 87 | export function createControls( | |
| 88 | canvas: HTMLCanvasElement, sensitivity = 2.2, bindings: Record<Action, string> = DEFAULT_BINDINGS, | |
| 89 | ): Controls { | |
| 55 | 90 | const press = createPress(LATCHED); |
| 56 | 91 | const input: Input = { ...NO_INPUT }; |
| 57 | 92 | let lookX = 0, lookY = 0; |
| 93 | let reverse = reverseBindings(bindings); | |
| 58 | 94 | const controls: Controls = { |
| 59 | 95 | takeLook() { |
| 60 | 96 | const out: [number, number] = [lookX, lookY]; |
| @@ -98,6 +134,9 @@ export function createControls(canvas: HTMLCanvasElement, sensitivity = 2.2): Co | ||
| 98 | 134 | if (action === "buy") controls.onBuy(); |
| 99 | 135 | else if (action === "menu") controls.onPause(); |
| 100 | 136 | }, |
| 137 | setBindings: (next) => { | |
| 138 | reverse = reverseBindings(next); | |
| 139 | }, | |
| 101 | 140 | clear: () => { |
| 102 | 141 | press.clear(); |
| 103 | 142 | lookX = 0; |
| @@ -115,14 +154,21 @@ export function createControls(canvas: HTMLCanvasElement, sensitivity = 2.2): Co | ||
| 115 | 154 | const stick = { x: 0, y: 0 }; |
| 116 | 155 | |
| 117 | 156 | function onKeyDown(event: KeyboardEvent): void { |
| 118 | const action = KEY_ACTIONS[event.code]; | |
| 157 | const action = reverse[event.code]; | |
| 158 | // Under pointer lock every key is the game's, whatever it is bound to: | |
| 159 | // an unbound Tab still switches focus off the canvas otherwise, and once | |
| 160 | // it has, a locked mouse is aiming at nothing the player can see move. | |
| 161 | if (controls.locked) event.preventDefault(); | |
| 119 | 162 | if (action === undefined) return; |
| 120 | if (event.code === "Tab" || event.code === "Space") event.preventDefault(); | |
| 163 | // Whatever is bound to these two actions gets the default the browser | |
| 164 | // would otherwise take even without a lock: a scoreboard key that also | |
| 165 | // switches tabs, or a jump key that also scrolls the page. | |
| 166 | if (action === "scoreboard" || action === "jump") event.preventDefault(); | |
| 121 | 167 | if (event.repeat) return; |
| 122 | 168 | controls.setAction(action, true); |
| 123 | 169 | } |
| 124 | 170 | function onKeyUp(event: KeyboardEvent): void { |
| 125 | const action = KEY_ACTIONS[event.code]; | |
| 171 | const action = reverse[event.code]; | |
| 126 | 172 | if (action !== undefined) controls.setAction(action, false); |
| 127 | 173 | } |
| 128 | 174 | function onLockChange(): void { |
src/client/main.ts +112 −2
| @@ -9,10 +9,11 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import { createAudio } from "./audio.ts"; |
| 12 | import { ACTION_LABELS, decideBind, labelForCode } from "./bind.ts"; | |
| 12 | 13 | import { buyMenu, type BuyRow } from "./buy.ts"; |
| 13 | 14 | import { attachFullscreen } from "./fullscreen.ts"; |
| 14 | 15 | import { drawHud } from "./hud.ts"; |
| 15 | import { attachButtons, createControls } from "./input.ts"; | |
| 16 | import { ACTIONS, attachButtons, createControls, DEFAULT_BINDINGS, type Action } from "./input.ts"; | |
| 16 | 17 | import { advance, TICK, waiting } from "./loop.ts"; |
| 17 | 18 | import { clamp, dist } from "./math.ts"; |
| 18 | 19 | import { COMPETITIVE, SHORT, type Rules } from "./match.ts"; |
| @@ -58,6 +59,8 @@ function boot(): void { | ||
| 58 | 59 | const muteButton = $<HTMLButtonElement>("mute"); |
| 59 | 60 | const fullscreenMenuButton = $<HTMLButtonElement>("fullscreen-menu"); |
| 60 | 61 | const fullscreenPauseButton = $<HTMLButtonElement>("fullscreen-pause"); |
| 62 | const controlsMenu = $("controls-menu"); | |
| 63 | const controlsPause = $("controls-pause"); | |
| 61 | 64 | |
| 62 | 65 | const viewLost = $("view-lost"); |
| 63 | 66 | const resumeButton = $<HTMLButtonElement>("resume"); |
| @@ -87,7 +90,7 @@ function boot(): void { | ||
| 87 | 90 | // click is already in force once the context exists to hear it. |
| 88 | 91 | audio.setVolume(settings.volume); |
| 89 | 92 | audio.setMuted(settings.muted); |
| 90 | const controls = createControls(scene, settings.sensitivity); | |
| 93 | const controls = createControls(scene, settings.sensitivity, settings.bindings); | |
| 91 | 94 | attachButtons(document, controls); |
| 92 | 95 | |
| 93 | 96 | // The menu's sensitivity field and the pause overlay's are the same number: |
| @@ -148,6 +151,110 @@ function boot(): void { | ||
| 148 | 151 | for (const field of [pauseSensField, volumeField, muteButton, pauseDifficultyField, fullscreenPauseButton]) |
| 149 | 152 | field.addEventListener("keydown", (event) => event.stopPropagation()); |
| 150 | 153 | |
| 154 | // --- controls: a button per action, in the menu and in the pause overlay, | |
| 155 | // built once and kept in sync by rewriting each button's own text — the | |
| 156 | // fixed 16-action list never changes shape, only its keys do. What a key | |
| 157 | // does to the map is bind.ts's decision; this only paints it and listens. | |
| 158 | const controlsPanelHtml = | |
| 159 | `<p class="hint">Click a key, then press the one you want it to be. ` + | |
| 160 | `Escape cancels. The touch pad underneath is unchanged.</p>` + | |
| 161 | `<div class="grid">${ACTIONS.map((action) => | |
| 162 | `<label>${ACTION_LABELS[action]}<button type="button" class="bind" data-bind="${action}"></button></label>`, | |
| 163 | ).join("")}</div>` + | |
| 164 | `<p class="hint bind-status" aria-live="polite"></p>` + | |
| 165 | `<div class="row"><button type="button" class="reset-bindings">Reset to defaults</button></div>`; | |
| 166 | controlsMenu.innerHTML = controlsPanelHtml; | |
| 167 | controlsPause.innerHTML = controlsPanelHtml; | |
| 168 | ||
| 169 | const refreshBindingButtons = (): void => { | |
| 170 | for (const container of [controlsMenu, controlsPause]) { | |
| 171 | for (const button of Array.from(container.querySelectorAll<HTMLButtonElement>("button.bind"))) { | |
| 172 | button.textContent = labelForCode(settings.bindings[button.dataset.bind as Action]); | |
| 173 | } | |
| 174 | } | |
| 175 | }; | |
| 176 | refreshBindingButtons(); | |
| 177 | ||
| 178 | // At most one capture is ever live: starting a new one, or anything that | |
| 179 | // takes the player off the panel — resuming, starting a match, the window | |
| 180 | // losing focus — cancels whatever capture was pending first. Without that | |
| 181 | // a forgotten capture's window listener outlives the click that opened it, | |
| 182 | // silently eating (and rebinding) the first key the next match sees. | |
| 183 | let cancelCapture: (() => void) | null = null; | |
| 184 | ||
| 185 | /** | |
| 186 | * One key, captured with the window itself in the capturing phase so it is | |
| 187 | * seen and stopped dead — `stopImmediatePropagation`, not just | |
| 188 | * `stopPropagation` — before it ever reaches input.ts's own window | |
| 189 | * listener or a second capture on another button: without that, the key | |
| 190 | * that rebinds fire also fires it. | |
| 191 | */ | |
| 192 | function captureBinding(action: Action, button: HTMLButtonElement, status: HTMLElement): void { | |
| 193 | cancelCapture?.(); | |
| 194 | const label = button.textContent; | |
| 195 | const finish = (): void => { | |
| 196 | window.removeEventListener("keydown", onKey, true); | |
| 197 | cancelCapture = null; | |
| 198 | }; | |
| 199 | cancelCapture = () => { | |
| 200 | finish(); | |
| 201 | button.textContent = label; | |
| 202 | }; | |
| 203 | button.textContent = "Press a key…"; | |
| 204 | status.textContent = ""; | |
| 205 | const onKey = (event: KeyboardEvent): void => { | |
| 206 | event.preventDefault(); | |
| 207 | event.stopImmediatePropagation(); | |
| 208 | if (event.repeat) return; | |
| 209 | finish(); | |
| 210 | if (event.code === "Escape") { | |
| 211 | button.textContent = label; | |
| 212 | return; | |
| 213 | } | |
| 214 | const outcome = decideBind(action, event.code, settings.bindings); | |
| 215 | if (!outcome.ok) { | |
| 216 | status.textContent = outcome.reason; | |
| 217 | button.textContent = label; | |
| 218 | return; | |
| 219 | } | |
| 220 | settings = { ...settings, bindings: outcome.bindings }; | |
| 221 | controls.setBindings(settings.bindings); | |
| 222 | saveSettings(settings); | |
| 223 | refreshBindingButtons(); | |
| 224 | }; | |
| 225 | window.addEventListener("keydown", onKey, true); | |
| 226 | } | |
| 227 | ||
| 228 | for (const container of [controlsMenu, controlsPause]) { | |
| 229 | const status = container.querySelector(".bind-status") as HTMLElement; | |
| 230 | for (const button of Array.from(container.querySelectorAll<HTMLButtonElement>("button.bind"))) { | |
| 231 | const action = button.dataset.bind as Action; | |
| 232 | button.addEventListener("click", () => captureBinding(action, button, status)); | |
| 233 | button.addEventListener("keydown", (event) => event.stopPropagation()); | |
| 234 | } | |
| 235 | const reset = container.querySelector(".reset-bindings") as HTMLButtonElement; | |
| 236 | reset.addEventListener("click", () => { | |
| 237 | cancelCapture?.(); | |
| 238 | settings = { ...settings, bindings: { ...DEFAULT_BINDINGS } }; | |
| 239 | controls.setBindings(settings.bindings); | |
| 240 | saveSettings(settings); | |
| 241 | status.textContent = ""; | |
| 242 | refreshBindingButtons(); | |
| 243 | }); | |
| 244 | reset.addEventListener("keydown", (event) => event.stopPropagation()); | |
| 245 | // The `<details>` itself: a focused summary is a control same as any | |
| 246 | // field here, so a Space that opens or closes it must not also reach the | |
| 247 | // window and latch a jump; and closing it while a capture is pending — | |
| 248 | // rather than pressing a key or Escape — still has to let the key go. | |
| 249 | const details = container.parentElement as HTMLDetailsElement; | |
| 250 | const summary = details.querySelector("summary") as HTMLElement; | |
| 251 | summary.addEventListener("keydown", (event) => event.stopPropagation()); | |
Approved. The delta since the head I approved is ten lines in Re-verified at this head rather than assumed: Everything else stands from the last round: Two things I am not holding the pull for, both noted rather than demanded:
Third head in a row where the deployed preview answers a plain-text 404 from the forge's edge (not the app's own HTML 404), so the pixels above are the forge's shots plus the local run; worth someone's attention, but it is not this pull's doing. |
||
| 252 | details.addEventListener("toggle", () => { | |
| 253 | if (!details.open) cancelCapture?.(); | |
| 254 | }); | |
| 255 | } | |
| 256 | window.addEventListener("blur", () => cancelCapture?.()); | |
| 257 | ||
| 151 | 258 | let world: World | null = null; |
| 152 | 259 | let matchId = ""; |
| 153 | 260 | let running = false; |
| @@ -365,6 +472,7 @@ function boot(): void { | ||
| 365 | 472 | } |
| 366 | 473 | |
| 367 | 474 | function start(): void { |
| 475 | cancelCapture?.(); | |
| 368 | 476 | const rules: Rules = lengthField.value === "long" ? COMPETITIVE : SHORT; |
| 369 | 477 | const team = sideField.value === "t" ? "t" : "ct"; |
| 370 | 478 | const name = nameField.value.trim().slice(0, 24) || "you"; |
| @@ -392,11 +500,13 @@ function boot(): void { | ||
| 392 | 500 | $("play").addEventListener("click", start); |
| 393 | 501 | resumeButton.addEventListener("click", () => { |
| 394 | 502 | if (renderer.lost) return; |
| 503 | cancelCapture?.(); | |
| 395 | 504 | show(pause, false); |
| 396 | 505 | refreshPad(); |
| 397 | 506 | lock(); |
| 398 | 507 | }); |
| 399 | 508 | const toMenu = (): void => { |
| 509 | cancelCapture?.(); | |
| 400 | 510 | // A match still going when the menu is reached — left from the pause |
| 401 | 511 | // overlay, not from a summary already posted as finished — gets the one |
| 402 | 512 | // post that says so, sent before the world it reads is gone. |
src/client/settings.test.ts +60 −3
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import { expect, test } from "bun:test"; |
| 8 | import { DEFAULT_BINDINGS } from "./input.ts"; | |
| 8 | 9 | import { DEFAULT_SETTINGS, parseSettings } from "./settings.ts"; |
| 9 | 10 | |
| 10 | 11 | test("nothing stored yet is the default", () => { |
| @@ -24,10 +25,10 @@ test("a JSON value that isn't an object is the default", () => { | ||
| 24 | 25 | |
| 25 | 26 | test("out-of-range numbers are clamped, not rejected", () => { |
| 26 | 27 | expect(parseSettings('{"volume":5,"sensitivity":9999}')).toEqual({ |
| 27 | muted: false, volume: 1, sensitivity: 12, difficulty: "normal", | |
| 28 | muted: false, volume: 1, sensitivity: 12, difficulty: "normal", bindings: DEFAULT_BINDINGS, | |
| 28 | 29 | }); |
| 29 | 30 | expect(parseSettings('{"volume":-3,"sensitivity":-1}')).toEqual({ |
| 30 | muted: false, volume: 0, sensitivity: 0.2, difficulty: "normal", | |
| 31 | muted: false, volume: 0, sensitivity: 0.2, difficulty: "normal", bindings: DEFAULT_BINDINGS, | |
| 31 | 32 | }); |
| 32 | 33 | }); |
| 33 | 34 | |
| @@ -43,7 +44,8 @@ test("muted is only ever true for exactly true", () => { | ||
| 43 | 44 | }); |
| 44 | 45 | |
| 45 | 46 | test("a whole, valid settings object round-trips", () => { |
| 46 | const stored = { muted: true, volume: 0.4, sensitivity: 3.5, difficulty: "hard" }; | |
| 47 | const bindings = { ...DEFAULT_BINDINGS, forward: "KeyZ" }; | |
| 48 | const stored = { muted: true, volume: 0.4, sensitivity: 3.5, difficulty: "hard", bindings }; | |
| 47 | 49 | expect(parseSettings(JSON.stringify(stored))).toEqual(stored); |
| 48 | 50 | }); |
| 49 | 51 | |
| @@ -66,3 +68,58 @@ test("a valid difficulty is kept exactly", () => { | ||
| 66 | 68 | expect(parseSettings('{"difficulty":"easy"}').difficulty).toBe("easy"); |
| 67 | 69 | expect(parseSettings('{"difficulty":"hard"}').difficulty).toBe("hard"); |
| 68 | 70 | }); |
| 71 | ||
| 72 | test("no stored bindings at all is precisely today's map", () => { | |
| 73 | expect(parseSettings(null).bindings).toEqual(DEFAULT_BINDINGS); | |
| 74 | expect(parseSettings('{"muted":true}').bindings).toEqual(DEFAULT_BINDINGS); | |
| 75 | }); | |
| 76 | ||
| 77 | test("bindings that are not an object is the default map", () => { | |
| 78 | expect(parseSettings('{"bindings":"KeyW"}').bindings).toEqual(DEFAULT_BINDINGS); | |
| 79 | expect(parseSettings('{"bindings":null}').bindings).toEqual(DEFAULT_BINDINGS); | |
| 80 | expect(parseSettings('{"bindings":[1,2,3]}').bindings).toEqual(DEFAULT_BINDINGS); | |
| 81 | }); | |
| 82 | ||
| 83 | test("a non-string code is dropped and the action falls back to its default", () => { | |
| 84 | expect(parseSettings('{"bindings":{"forward":123}}').bindings.forward).toBe("KeyW"); | |
| 85 | expect(parseSettings('{"bindings":{"forward":null}}').bindings.forward).toBe("KeyW"); | |
| 86 | expect(parseSettings('{"bindings":{"forward":["KeyZ"]}}').bindings.forward).toBe("KeyW"); | |
| 87 | expect(parseSettings('{"bindings":{"forward":""}}').bindings.forward).toBe("KeyW"); | |
| 88 | }); | |
| 89 | ||
| 90 | test("an unknown action in storage is dropped, not carried over", () => { | |
| 91 | const parsed = parseSettings('{"bindings":{"grapple":"KeyG"}}'); | |
| 92 | expect(parsed.bindings).toEqual(DEFAULT_BINDINGS); | |
| 93 | expect(Object.keys(parsed.bindings)).not.toContain("grapple"); | |
| 94 | }); | |
| 95 | ||
| 96 | test("a valid partial rebind keeps the rest of the default map", () => { | |
| 97 | const parsed = parseSettings('{"bindings":{"forward":"KeyZ","left":"KeyX"}}'); | |
| 98 | expect(parsed.bindings).toEqual({ ...DEFAULT_BINDINGS, forward: "KeyZ", left: "KeyX" }); | |
| 99 | }); | |
| 100 | ||
| 101 | test("two actions given the same code: the first in the fixed order keeps it, the second falls back to its own default", () => { | |
| 102 | const parsed = parseSettings('{"bindings":{"forward":"KeyX","back":"KeyX"}}'); | |
| 103 | expect(parsed.bindings.forward).toBe("KeyX"); | |
| 104 | expect(parsed.bindings.back).toBe(DEFAULT_BINDINGS.back); | |
| 105 | }); | |
| 106 | ||
| 107 | test("a hostile blob that steals another action's default still resolves to a unique map", () => { | |
| 108 | // "forward" claims "back"'s default code; "back" itself asks for nothing so | |
| 109 | // it would normally fall back to that same default — but it is taken, so it | |
| 110 | // resolves to the first default nothing else has claimed. | |
| 111 | const parsed = parseSettings('{"bindings":{"forward":"KeyS"}}'); | |
| 112 | const codes = Object.values(parsed.bindings); | |
| 113 | expect(new Set(codes).size).toBe(codes.length); | |
| 114 | expect(parsed.bindings.forward).toBe("KeyS"); | |
| 115 | }); | |
| 116 | ||
| 117 | test("a corrupt blob can never leave an action unbound", () => { | |
| 118 | const hostile = JSON.stringify({ | |
| 119 | bindings: Object.fromEntries(Object.keys(DEFAULT_BINDINGS).map((action) => [action, "KeyW"])), | |
| 120 | }); | |
| 121 | const parsed = parseSettings(hostile); | |
| 122 | const codes = Object.values(parsed.bindings); | |
| 123 | expect(codes.every((code) => typeof code === "string" && code.length > 0)).toBe(true); | |
| 124 | expect(new Set(codes).size).toBe(codes.length); | |
| 125 | }); |
src/client/settings.ts +48 −10
| @@ -1,23 +1,27 @@ | ||
| 1 | 1 | /** |
| 2 | * Settings a player can reach mid-match: mute, volume, look sensitivity and | |
| 3 | * bot difficulty. Stored in localStorage beside `cs.name`. `parseSettings` is | |
| 4 | * the boundary — a missing, malformed, out-of-range or unknown value falls | |
| 5 | * back to the default and never puts NaN or an unrecognised string into the | |
| 6 | * game. What a difficulty setting actually does to a bot is bots.ts's | |
| 7 | * business; this file only knows the three names it can be. | |
| 2 | * Settings a player can reach mid-match: mute, volume, look sensitivity, bot | |
| 3 | * difficulty and key bindings. Stored in localStorage beside `cs.name`. | |
| 4 | * `parseSettings` is the boundary — a missing, malformed, out-of-range or | |
| 5 | * unknown value falls back to the default and never puts NaN, an | |
| 6 | * unrecognised string or an unbound action into the game. What a difficulty | |
| 7 | * setting actually does to a bot is bots.ts's business, and what a bound key | |
| 8 | * actually does is input.ts's; this file only knows the shapes they can be. | |
| 8 | 9 | */ |
| 9 | 10 | |
| 10 | 11 | import { DEFAULT_DIFFICULTY, DIFFICULTIES, type Difficulty } from "./bots.ts"; |
| 12 | import { ACTIONS, DEFAULT_BINDINGS, type Action } from "./input.ts"; | |
| 11 | 13 | |
| 12 | 14 | export type Settings = { |
| 13 | 15 | muted: boolean; |
| 14 | 16 | volume: number; |
| 15 | 17 | sensitivity: number; |
| 16 | 18 | difficulty: Difficulty; |
| 19 | bindings: Record<Action, string>; | |
| 17 | 20 | }; |
| 18 | 21 | |
| 19 | 22 | export const DEFAULT_SETTINGS: Settings = { |
| 20 | 23 | muted: false, volume: 1, sensitivity: 2.2, difficulty: DEFAULT_DIFFICULTY, |
| 24 | bindings: DEFAULT_BINDINGS, | |
| 21 | 25 | }; |
| 22 | 26 | export const SENSITIVITY_MIN = 0.2; |
| 23 | 27 | export const SENSITIVITY_MAX = 12; |
| @@ -25,6 +29,37 @@ export const SENSITIVITY_MAX = 12; | ||
| 25 | 29 | const isDifficulty = (value: unknown): value is Difficulty => |
| 26 | 30 | typeof value === "string" && (DIFFICULTIES as readonly string[]).includes(value); |
| 27 | 31 | |
| 32 | /** | |
| 33 | * Every action ends up with a code, and no two actions ever end up with the | |
| 34 | * same one. An action's stored code is kept if it is a non-empty string and | |
| 35 | * nothing earlier in `ACTIONS` order has already claimed it; otherwise it | |
| 36 | * falls back to its own default, and if even that has already been claimed — | |
| 37 | * only possible when an earlier action was explicitly bound to this action's | |
| 38 | * default — to the first default nothing has claimed yet. Both fallbacks are | |
| 39 | * exhaustive: there are exactly as many actions as defaults, so one is always | |
| 40 | * left for whichever action reaches the end still unclaimed. | |
| 41 | */ | |
| 42 | function parseBindings(raw: unknown): Record<Action, string> { | |
| 43 | const obj = typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {}; | |
| 44 | const used = new Set<string>(); | |
| 45 | const bindings = {} as Record<Action, string>; | |
| 46 | const firstUnclaimedDefault = (): string => { | |
| 47 | for (const action of ACTIONS) if (!used.has(DEFAULT_BINDINGS[action])) return DEFAULT_BINDINGS[action]; | |
| 48 | /* unreachable: as many actions as defaults, and one claim per action so far */ | |
| 49 | return DEFAULT_BINDINGS[ACTIONS[0]]; | |
| 50 | }; | |
| 51 | for (const action of ACTIONS) { | |
| 52 | const stored = obj[action]; | |
| 53 | const wanted = typeof stored === "string" && stored.length > 0 ? stored : DEFAULT_BINDINGS[action]; | |
| 54 | const code = !used.has(wanted) ? wanted | |
| 55 | : !used.has(DEFAULT_BINDINGS[action]) ? DEFAULT_BINDINGS[action] | |
| 56 | : firstUnclaimedDefault(); | |
| 57 | bindings[action] = code; | |
| 58 | used.add(code); | |
| 59 | } | |
| 60 | return bindings; | |
| 61 | } | |
| 62 | ||
| 28 | 63 | const KEY = "cs.settings"; |
| 29 | 64 | |
| 30 | 65 | const bounded = (value: unknown, lo: number, hi: number, fallback: number): number => { |
| @@ -34,28 +69,31 @@ const bounded = (value: unknown, lo: number, hi: number, fallback: number): numb | ||
| 34 | 69 | |
| 35 | 70 | /** Pure: whatever was in storage, in or out of shape, becomes safe settings. */ |
| 36 | 71 | export function parseSettings(raw: string | null): Settings { |
| 37 | if (raw === null) return { ...DEFAULT_SETTINGS }; | |
| 72 | if (raw === null) return defaultSettings(); | |
| 38 | 73 | let parsed: unknown; |
| 39 | 74 | try { |
| 40 | 75 | parsed = JSON.parse(raw); |
| 41 | 76 | } catch { |
| 42 | return { ...DEFAULT_SETTINGS }; | |
| 77 | return defaultSettings(); | |
| 43 | 78 | } |
| 44 | if (typeof parsed !== "object" || parsed === null) return { ...DEFAULT_SETTINGS }; | |
| 79 | if (typeof parsed !== "object" || parsed === null) return defaultSettings(); | |
| 45 | 80 | const obj = parsed as Record<string, unknown>; |
| 46 | 81 | return { |
| 47 | 82 | muted: obj.muted === true, |
| 48 | 83 | volume: bounded(obj.volume, 0, 1, DEFAULT_SETTINGS.volume), |
| 49 | 84 | sensitivity: bounded(obj.sensitivity, SENSITIVITY_MIN, SENSITIVITY_MAX, DEFAULT_SETTINGS.sensitivity), |
| 50 | 85 | difficulty: isDifficulty(obj.difficulty) ? obj.difficulty : DEFAULT_SETTINGS.difficulty, |
| 86 | bindings: parseBindings(obj.bindings), | |
| 51 | 87 | }; |
| 52 | 88 | } |
| 53 | 89 | |
| 90 | const defaultSettings = (): Settings => ({ ...DEFAULT_SETTINGS, bindings: { ...DEFAULT_BINDINGS } }); | |
| 91 | ||
| 54 | 92 | export function loadSettings(): Settings { |
| 55 | 93 | try { |
| 56 | 94 | return parseSettings(localStorage.getItem(KEY)); |
| 57 | 95 | } catch { |
| 58 | return { ...DEFAULT_SETTINGS }; | |
| 96 | return defaultSettings(); | |
| 59 | 97 | } |
| 60 | 98 | } |
| 61 | 99 |
src/pages/game.ts +23 −2
| @@ -99,7 +99,14 @@ small.commit { color:var(--dim); font-size:.75rem; } | ||
| 99 | 99 | } |
| 100 | 100 | , { position:absolute; inset:0; width:100%; height:100%; display:block; } |
| 101 | 101 | { pointer-events:none; } |
| 102 | .overlay { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; | |
| 102 | .overlay { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; | |
| 103 | /* "safe": a centred flex container does not let you scroll to its | |
| 104 | own top overflow, so content taller than the stage — a menu with | |
| 105 | its Controls panel open — would hide Play above the fold with no | |
| 106 | way to reach it. Where "safe center" is not understood the whole | |
| 107 | declaration is dropped and the box falls back to flex-start, | |
| 108 | which is scrollable either way. */ | |
| 109 | justify-content:safe center; | |
| 103 | 110 | gap:.75rem; background:rgba(8,10,14,.82); text-align:center; padding:1rem; overflow:auto; |
| 104 | 111 | /* #stage is touch-action:none for the look drag; an overlay taller |
| 105 | 112 | than the stage still has to scroll under a thumb. */ |
| @@ -119,6 +126,17 @@ button:disabled { opacity:.42; cursor:not-allowed; } | ||
| 119 | 126 | button.primary { background:563eb; border-color:563eb; font-weight:600; } |
| 120 | 127 | .grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(11rem,1fr)); gap:.5rem; width:min(40rem,100%); } |
| 121 | 128 | .hint, .overlay .hint { font-size:.78rem; } |
| 129 | /* Collapsed by default: a 16-row grid inside a fixed-height, centred overlay | |
| 130 | would otherwise push Play and the match settings above the fold with no | |
| 131 | way to scroll back up to them. <details> is reachable and toggled with a | |
| 132 | keyboard on its own, with no script required to open it. */ | |
| 133 | .controls { width:min(38rem,100%); margin-top:.3rem; text-align:left; } | |
| 134 | .controls summary { min-height:44px; display:flex; align-items:center; justify-content:center; | |
| 135 | font-weight:600; cursor:pointer; } | |
| 136 | .controls .grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(7.5rem,1fr)); gap:.5rem; width:100%; margin-top:.5rem; } | |
| 137 | .controls .grid label { gap:.3rem; } | |
| 138 | .controls .grid button { width:100%; } | |
| 139 | .controls .bind-status { min-height:1.1em; color:; } | |
| 122 | 140 | .buy-item { display:flex; flex-direction:column; align-items:flex-start; gap:.1rem; text-align:left; white-space:normal; } |
| 123 | 141 | .buy-item .buy-index { font-size:.7rem; color:var(--dim); } |
| 124 | 142 | .buy-item .buy-why { font-size:.72rem; color:var(--dim); min-height:1em; } |
| @@ -212,12 +230,14 @@ export function gamePage(db: Database, options: PageOptions): string { | ||
| 212 | 230 | mouse to aim and fire · <kbd>R</kbd> reload · <kbd>E</kbd> plant, defuse or take a gun · <kbd>B</kbd> buy · |
| 213 | 231 | right click or <kbd>Q</kbd> to scope · <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> guns · |
| 214 | 232 | <kbd>4</kbd> the HE grenade, thrown with fire · <kbd>Tab</kbd> scores · |
| 215 | <kbd>Esc</kbd> pauses the match, where sound and sensitivity live too</p> | |
| 233 | <kbd>Esc</kbd> pauses the match, where sound and sensitivity live too. Every key above is | |
| 234 | the default — rebind any of them under <b>Controls</b> below.</p> | |
| 216 | 235 | <p class="hint">On a phone: the left of the view is a thumb stick, the right aims, a second |
| 217 | 236 | finger on the right fires, and the pad under the view does the rest — guns, the grenade, |
| 218 | 237 | scores, buy and <code>menu</code>, which pauses the match and is where you leave it from.</p> |
| 219 | 238 | <p class="hint">Paused means paused: the world stops while the overlay is up, and so does |
| 220 | 239 | tabbing away — the match is waiting for you when you come back.</p> |
| 240 | <details class="controls"><summary>Controls</summary><div id="controls-menu"></div></details> | |
Approved. All five findings from the last round are fixed, and I re-read every changed line at ba3349d rather than taking the changelog's word for it.
No security surface changes: nothing server-side is touched, key codes reach the DOM through Two nits, neither worth holding the pull and neither new here: pressing Space on the focused |
||
| 221 | 241 | </div> |
| 222 | 242 | <div class="overlay" id="pause" hidden> |
| 223 | 243 | <h2>Paused</h2> |
| @@ -238,6 +258,7 @@ export function gamePage(db: Database, options: PageOptions): string { | ||
| 238 | 258 | <button type="button" id="fullscreen-pause" aria-pressed="false" hidden>Fullscreen</button> |
| 239 | 259 | </div> |
| 240 | 260 | <p class="hint">Difficulty takes effect from the next round.</p> |
| 261 | <details class="controls"><summary>Controls</summary><div id="controls-pause"></div></details> | |
| 241 | 262 | <div class="row"><button type="button" class="primary" id="resume">Resume</button><button type="button" id="quit">Leave match</button></div> |
| 242 | 263 | </div> |
| 243 | 264 | <div class="overlay" id="summary" hidden> |
probe