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

forked from trevato/cs

cant change sens mid game. this is annoying. any other settings we should hav… #413

Merged opened this in answer to

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

Merged

vesper/cs-engine:vesper/401-cant-change-sens-mid-game-this-is-annoyi → main 1 commit fork

  • Merged. vesper/401-cant-change-sens-mid-game-this-is-annoyi was merged into main as 948bd72.

Review

Sign in to review this change.
8 files changed +344 −16
README.md +13 −2
@@ -575,8 +575,7 @@ select sits beside Sensitivity in the menu. It starts wherever the machine
575575 already asks: the page reads `(prefers-reduced-motion: reduce)` through
576576 `matchMedia` once at boot and starts at **Reduced** if the system says so;
577577 **Full** is the game exactly as it has always drawn. The player can move it
578 either way, and a match reads it as it starts, the way it reads the
579 sensitivity. **Reduced** turns down exactly two things, both decoration: the
578 either way, from the menu or mid-match from the pause panel. **Reduced** turns down exactly two things, both decoration: the
580579 walk's sway on the gun in your hands (it goes to zero, the gun a player
581580 standing still already holds), and the fade on the red arc that says where a
582581 hit came from, which holds its strength for its life and then goes. Everything
@@ -587,6 +586,18 @@ not kick is an aim advantage, not an accessibility feature. `src/client/motion.t
587586 holds the two values and the line between them; `scene` and `hud` take the one
588587 value with the frame.
589588
589 **Settings change mid-match, from the pause panel.** Sensitivity, Touch sens,
590 Motion and **Sound** (on or off — the mix has one level, so there is no volume
591 slider to pretend otherwise) are in the menu and again in the pause panel. A
592 change in either place applies on the spot — the next mouse movement turns at
593 the new number, without leaving the round — is copied into the other place, and
594 is remembered in the browser's `localStorage` (`cs.sens`, `cs.touchsens`,
595 `cs.motion`, `cs.sound`), so the sensitivity you settled on is there on your
596 next visit. What storage holds is parsed like any other input: a number outside
597 0.2–12 is the nearest end, and junk is the default. Side, length and map stay in
598 the menu, because changing them is starting a different match.
599 `src/client/settings.ts` is the whole of it.
600
590601 **Windows is asked for the mouse the way Windows needs.** `Controls.lock()` asks
591602 for `unadjustedMovement`, because without it Chromium on Windows hands the page
592603 a `movementX` with the OS's pointer speed and "enhance pointer precision"
src/client/main.test.ts +69
@@ -388,6 +388,75 @@ test("a thumb aims at Touch sens, and the mouse keeps Sensitivity", () => {
388388 expect(five.mouse).toBeCloseTo(-0.484, 9);
389389 });
390390
391 test("#401: Sensitivity changed in the pause panel turns the view at once, fills the menu and is remembered", () => {
392 const stage = playing();
393 stage.lock(true);
394 const world = (stage.step(FRAME), worldOf(stage)) as World;
395 world.match.phase = "live";
396 world.match.timer = 60;
397 stage.press("KeyP");
398 expect(stage.by("pause").hidden).toBe(false);
399 // The pause panel carries the same value the match started on.
400 expect(stage.by("psens").value).toBe("2.2");
401
402 // Typed in the pause, mid-match: no new match, no Play, and the next mouse
403 // movement is 10 counts × 4.4 × 0.022° = 0.968° of yaw.
404 const field = stage.by("psens");
405 field.value = "4.4";
406 field.fire("input");
407 expect(stage.by("sens").value).toBe("4.4");
408 expect(stage.store.get("cs.sens")).toBe("4.4");
409 stage.by("resume").fire("click");
410 stage.lock(true);
411 const from = world.me.yaw;
412 stage.window.fire("mousemove", { movementX: 10, movementY: 0 });
413 stage.step(FRAME);
414 expect(world.me.yaw).toBeCloseTo(from - 0.968, 9);
415 expect(worldOf(stage)).toBe(world);
416
417 // Out of range is the end of the range once the field is let go of.
418 field.value = "40";
419 field.fire("change");
420 expect(field.value).toBe("12");
421 expect(stage.by("sens").value).toBe("12");
422 });
423
424 test("#401: settings come back on the next visit, and junk in storage is not believed", () => {
425 const stage = fakeStage();
426 stage.store.set("cs.sens", "1.5");
427 stage.store.set("cs.touchsens", "not a number");
428 stage.store.set("cs.motion", "reduced");
429 painted(start(stage));
430 for (const [id, want] of [["sens", "1.5"], ["psens", "1.5"], ["touchsens", "2.2"], ["motion", "reduced"], ["pmotion", "reduced"]]) {
431 expect([id, stage.by(id as string).value]).toEqual([id, want]);
432 }
433 stage.by("play").fire("click");
434 stage.lock(true);
435 const world = (stage.step(FRAME), worldOf(stage)) as World;
436 world.match.phase = "live";
437 world.match.timer = 60;
438 const from = world.me.yaw;
439 stage.window.fire("mousemove", { movementX: 10, movementY: 0 });
440 stage.step(FRAME);
441 // 10 × 1.5 × 0.022°.
442 expect(world.me.yaw).toBeCloseTo(from - 0.33, 9);
443 });
444
445 test("#401: Sound off in the pause mutes the match, and on brings it back", () => {
446 const stage = playing();
447 const audio = stage.host.createAudio();
448 expect(audio.muted).toBe(false);
449 const sound = stage.by("psound");
450 sound.value = "off";
451 sound.fire("change");
452 expect(audio.muted).toBe(true);
453 expect(stage.by("sound").value).toBe("off");
454 expect(stage.store.get("cs.sound")).toBe("off");
455 sound.value = "on";
456 sound.fire("change");
457 expect(audio.muted).toBe(false);
458 });
459
391460 test("the whole mouse plays the game: the wheel jumps and the thumb buttons do not navigate", () => {
392461 const stage = playing();
393462 stage.lock(true);
src/client/main.ts +67 −8
@@ -89,6 +89,7 @@ import type { Level } from "./level.ts";
8989 import { FOV, fovFor, lookAt } from "./look.ts";
9090 import { COMPETITIVE, SHORT, type Rules, type Team } from "./match.ts";
9191 import { motionFor, parseMotion, REDUCED_MOTION_QUERY, type Motion } from "./motion.ts";
92 import { parseSens, parseSound, readSettings, SENS, SETTING_KEYS, settingStrings, type Settings, withSetting } from "./settings.ts";
9293 import { LAYERS, MATERIALS, TEX_SIZE } from "./materials.ts";
9394 import { meshFor } from "./models.ts";
9495 import { parsePacked } from "./packed.ts";
@@ -395,6 +396,7 @@ export function boot(host: Host): Frame {
395396 const motionField = by<Field>("motion");
396397 motionField.value = motionFor(win.matchMedia(REDUCED_MOTION_QUERY).matches);
397398 let motion: Motion = parseMotion(motionField.value);
399 const soundField = by<Field>("sound");
398400 const mapField = by<Field>("map");
399401 // The id a person pasted, the line that answers them, and the three places
400402 // the match being played says what it is: the section under the stage (the
@@ -523,10 +525,63 @@ export function boot(host: Host): Frame {
523525 const controls = createControls(
524526 { surface: sceneCanvas, window: win, document: doc },
525527 {
526 keys: CS_KEYS, mouse: CS_MOUSE, wheel: CS_WHEEL, sensitivity: Number(sensField.value) || 2.2,
527 touchSensitivity: Number(touchSensField.value) || 2.2,
528 keys: CS_KEYS, mouse: CS_MOUSE, wheel: CS_WHEEL, sensitivity: SENS.start, touchSensitivity: SENS.start,
528529 },
529530 );
531
532 /**
533 * The settings (#401), which are in the menu and again in the pause panel,
534 * because a sensitivity that is wrong is found out mid-round. Each setting is
535 * a pair of fields — the menu's and the pause panel's — and one value: a
536 * change in either field is parsed, applied on the spot, written into the
537 * other field of the pair and remembered. What storage remembers wins over
538 * the page's own fields at boot, and those fields are where a player who
539 * never chose starts — the motion the system asked for among them.
540 */
541 const settingFields: Record<keyof Settings, readonly [Field, Field]> = {
542 sens: [sensField, by<Field>("psens")],
543 touchSens: [touchSensField, by<Field>("ptouchsens")],
544 motion: [motionField, by<Field>("pmotion")],
545 sound: [soundField, by<Field>("psound")],
546 };
547 const settingNames = Object.keys(settingFields) as (keyof Settings)[];
548 let settings: Settings = readSettings((key) => host.storage.getItem(key), {
549 sens: parseSens(sensField.value),
550 touchSens: parseSens(touchSensField.value),
551 motion: parseMotion(motionField.value),
552 sound: parseSound(soundField.value),
553 });
554 /** Every field of every pair saying what `settings` says, except the one being typed in. */
555 const showSettings = (typing?: Field): void => {
556 const said = settingStrings(settings);
557 for (const name of settingNames) {
558 for (const field of settingFields[name]) if (field !== typing) field.value = said[name];
559 }
560 };
561 /** What the settings change, changed: the look, the decoration and the sound. */
562 const applySettings = (): void => {
563 controls.sensitivity = settings.sens;
564 controls.touchSensitivity = settings.touchSens;
565 motion = settings.motion;
566 audio.muted = settings.sound === "off";
567 };
568 for (const name of settingNames) {
569 for (const field of settingFields[name]) {
570 // `input` while a number is typed or spun, so the look follows the field
571 // as it moves; the field itself is only tidied (`2.25` to `2.3`, `40` to
572 // `12`) on `change`, when the person has finished with it.
573 const update = (tidy: boolean) => (): void => {
574 settings = withSetting(settings, name, field.value);
575 showSettings(tidy ? undefined : field);
576 applySettings();
577 host.storage.setItem(SETTING_KEYS[name], settingStrings(settings)[name]);
578 };
579 field.addEventListener("input", update(false));
580 field.addEventListener("change", update(true));
581 }
582 }
583 showSettings();
584 applySettings();
530585 attachButtons(doc.querySelectorAll("[data-action]"), controls, { tap: CS_TAP });
531586
532587 // The stage on the whole screen: the button over it, and F, which is bound to
@@ -1241,9 +1296,11 @@ export function boot(host: Host): Frame {
12411296 posted = -1;
12421297 postedFinal = false;
12431298 loop.reset();
1244 controls.sensitivity = clamp(Number(sensField.value) || 2.2, 0.2, 12);
1245 controls.touchSensitivity = clamp(Number(touchSensField.value) || 2.2, 0.2, 12);
1246 motion = parseMotion(motionField.value);
1299 // The settings are already applied — they apply as they change — but a
1300 // field set in code fires no event, so the menu's fields are read once more.
1301 for (const name of settingNames) settings = withSetting(settings, name, settingFields[name][0].value);
1302 showSettings();
1303 applySettings();
12471304 running = true;
12481305 lastSound = 0;
12491306 show(menu, false);
@@ -1306,9 +1363,11 @@ export function boot(host: Host): Frame {
13061363 roomBuy = null;
13071364 posted = -1;
13081365 loop.reset();
1309 controls.sensitivity = clamp(Number(sensField.value) || 2.2, 0.2, 12);
1310 controls.touchSensitivity = clamp(Number(touchSensField.value) || 2.2, 0.2, 12);
1311 motion = parseMotion(motionField.value);
1366 // The settings are already applied — they apply as they change — but a
1367 // field set in code fires no event, so the menu's fields are read once more.
1368 for (const name of settingNames) settings = withSetting(settings, name, settingFields[name][0].value);
1369 showSettings();
1370 applySettings();
13121371 running = true;
13131372 lastSound = 0;
13141373 show(menu, false);
src/client/settings.test.ts added +61
@@ -0,0 +1,61 @@
1 import { expect, test } from "bun:test";
2 import { parseSens, parseSound, readSettings, SENS, SETTING_KEYS, settingStrings, type Settings, withSetting } from "./settings.ts";
3
4 const DEFAULTS: Settings = { sens: SENS.start, touchSens: SENS.start, motion: "full", sound: "on" };
5
6 test("a sensitivity is a number inside the range, to a tenth, or the fallback", () => {
7 expect(parseSens("3.5")).toBe(3.5);
8 expect(parseSens(4)).toBe(4);
9 expect(parseSens("2.25")).toBe(2.3);
10 expect(parseSens("40")).toBe(SENS.max);
11 expect(parseSens("-1")).toBe(SENS.min);
12 expect(parseSens("0")).toBe(SENS.min);
13 expect(parseSens("")).toBe(SENS.start);
14 expect(parseSens(" ")).toBe(SENS.start);
15 expect(parseSens("abc", 5)).toBe(5);
16 expect(parseSens("Infinity", 5)).toBe(5);
17 expect(parseSens(null, 5)).toBe(5);
18 expect(parseSens({}, 5)).toBe(5);
19 });
20
21 test("sound is off only when it says off", () => {
22 expect(parseSound("off")).toBe("off");
23 expect(parseSound("on")).toBe("on");
24 expect(parseSound("OFF")).toBe("on");
25 expect(parseSound(undefined)).toBe("on");
26 });
27
28 test("storage wins over the page's defaults, and junk in it is parsed rather than trusted", () => {
29 const store = new Map<string, string>([
30 [SETTING_KEYS.sens, "1.3"],
31 [SETTING_KEYS.touchSens, "nonsense"],
32 [SETTING_KEYS.motion, "sideways"],
33 [SETTING_KEYS.sound, "off"],
34 ]);
35 const read = readSettings((key) => store.get(key) ?? null, { ...DEFAULTS, touchSens: 4, motion: "reduced" });
36 expect(read).toEqual({ sens: 1.3, touchSens: 4, motion: "full", sound: "off" });
37 });
38
39 test("nothing stored is the page's own defaults, the system's motion among them", () => {
40 const defaults: Settings = { ...DEFAULTS, motion: "reduced" };
41 expect(readSettings(() => null, defaults)).toEqual(defaults);
42 });
43
44 test("one field changes one setting, and an emptied sensitivity keeps the one it had", () => {
45 const at: Settings = { ...DEFAULTS, sens: 3 };
46 expect(withSetting(at, "sens", "5")).toEqual({ ...at, sens: 5 });
47 expect(withSetting(at, "sens", "")).toEqual(at);
48 expect(withSetting(at, "touchSens", "0.5")).toEqual({ ...at, touchSens: 0.5 });
49 expect(withSetting(at, "motion", "reduced")).toEqual({ ...at, motion: "reduced" });
50 expect(withSetting(at, "sound", "off")).toEqual({ ...at, sound: "off" });
51 });
52
53 test("what a field and a storage key hold reads back as the same settings", () => {
54 const settings: Settings = { sens: 0.8, touchSens: 11.5, motion: "reduced", sound: "off" };
55 const said = settingStrings(settings);
56 expect(said).toEqual({ sens: "0.8", touchSens: "11.5", motion: "reduced", sound: "off" });
57 expect(readSettings((key) => {
58 const name = (Object.keys(SETTING_KEYS) as (keyof Settings)[]).find((n) => SETTING_KEYS[n] === key);
59 return name === undefined ? null : said[name];
60 }, DEFAULTS)).toEqual(settings);
61 });
src/client/settings.ts added +92
@@ -0,0 +1,92 @@
1 /**
2 * Settings: what a player tunes about how the game feels rather than which
3 * match it is — the mouse's sensitivity, the thumb's, motion, and sound.
4 *
5 * They are the one part of the menu that is also in the pause panel, because
6 * they are the part a person finds out is wrong only once they are playing
7 * (#401). A change in either place applies on the frame it is made, is written
8 * into the other place's field so the two never disagree, and is remembered in
9 * storage — so the sensitivity you settled on mid-round is the one you get on
10 * the next visit. The match settings (side, length, map) stay in the menu:
11 * changing them is starting a different match.
12 *
13 * Everything here is plain data. Every value that comes in — a field, a stored
14 * string somebody could have edited — goes through a `parse` below and comes
15 * out a value the game can use; nothing here can throw.
16 */
17
18 import { type Motion, parseMotion } from "./motion.ts";
19
20 /** The range both sensitivity fields offer, and what a page that says nothing starts at. */
21 export const SENS = { min: 0.2, max: 12, step: 0.1, start: 2.2 } as const;
22
23 /** Sound is on or off; there is no volume, because the master bus has one level. */
24 export const SOUNDS = ["on", "off"] as const;
25 export type Sound = (typeof SOUNDS)[number];
26 export const SOUND_LABELS: Record<Sound, string> = { on: "On", off: "Off" };
27
28 export type Settings = { sens: number; touchSens: number; motion: Motion; sound: Sound };
29
30 /** Where each setting is remembered, beside `cs.name` and the rest of the menu. */
31 export const SETTING_KEYS: Record<keyof Settings, string> = {
32 sens: "cs.sens",
33 touchSens: "cs.touchsens",
34 motion: "cs.motion",
35 sound: "cs.sound",
36 };
37
38 /**
39 * A sensitivity out of whatever a field or a store holds. A number outside the
40 * range is the nearest end of it, rounded to the field's tenth so a stored
41 * value reads back as the field would show it; anything that is not a number —
42 * empty, `abc`, `Infinity` — is `fallback`.
43 */
44 export const parseSens = (value: unknown, fallback: number = SENS.start): number => {
45 const n = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : NaN;
46 if (!Number.isFinite(n)) return fallback;
47 // Both ends are whole tenths, so a tenth-rounded value inside them stays inside.
48 return Math.round(Math.min(SENS.max, Math.max(SENS.min, n)) * 10) / 10;
49 };
50
51 /** Sound out of a field or a store: `off` is off, and anything else is the default, on. */
52 export const parseSound = (value: unknown): Sound => (value === "off" ? "off" : "on");
53
54 /**
55 * The settings a page starts with: what storage remembers, where it remembers
56 * anything, over `defaults` — which is the page's own fields, so a motion the
57 * system asked to reduce is still where a player who never chose starts.
58 */
59 export const readSettings = (get: (key: string) => string | null, defaults: Settings): Settings => {
60 const sens = get(SETTING_KEYS.sens);
61 const touchSens = get(SETTING_KEYS.touchSens);
62 const motion = get(SETTING_KEYS.motion);
63 const sound = get(SETTING_KEYS.sound);
64 return {
65 sens: sens === null ? defaults.sens : parseSens(sens, defaults.sens),
66 touchSens: touchSens === null ? defaults.touchSens : parseSens(touchSens, defaults.touchSens),
67 motion: motion === null ? defaults.motion : parseMotion(motion),
68 sound: sound === null ? defaults.sound : parseSound(sound),
69 };
70 };
71
72 /**
73 * The settings after one field said `raw`: that one setting parsed, the rest
74 * as they were. A sensitivity that is not a number yet — a field emptied to
75 * type a new one — keeps the one it had rather than jumping to the default.
76 */
77 export const withSetting = (settings: Settings, key: keyof Settings, raw: unknown): Settings => {
78 switch (key) {
79 case "sens": return { ...settings, sens: parseSens(raw, settings.sens) };
80 case "touchSens": return { ...settings, touchSens: parseSens(raw, settings.touchSens) };
81 case "motion": return { ...settings, motion: parseMotion(raw) };
82 case "sound": return { ...settings, sound: parseSound(raw) };
83 }
84 };
85
86 /** Every setting as the string its field and its storage key hold. */
87 export const settingStrings = (settings: Settings): Record<keyof Settings, string> => ({
88 sens: String(settings.sens),
89 touchSens: String(settings.touchSens),
90 motion: settings.motion,
91 sound: settings.sound,
92 });
src/client/stage.test-helper.ts +1 −1
@@ -165,7 +165,7 @@ export type FakeStage = {
165165 * contract between the page and this file, so `src/server.test.ts` checks the
166166 * served page against this same list rather than a second copy of it.
167167 */
168 export const PAGE_IDS = ["weights", "stage", "scene", "hud", "menu", "pause", "summary", "buy", "buyshelf", "buysays", "touch", "stats", "saved", "name", "length", "side", "sens", "touchsens", "motion", "map", "mapseed", "matchid", "onid", "onmap", "today", "ontoday", "willplay", "plan", "planalt", "copymap", "play", "room", "roomside", "joinroom", "makeroom", "copyroom", "roomsaid", "prep", "resume", "quit", "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", "buyshelf", "buysays", "touch", "stats", "saved", "name", "length", "side", "sens", "touchsens", "motion", "sound", "map", "mapseed", "matchid", "onid", "onmap", "today", "ontoday", "willplay", "plan", "planalt", "copymap", "play", "room", "roomside", "joinroom", "makeroom", "copyroom", "roomsaid", "prep", "resume", "quit", "psens", "ptouchsens", "pmotion", "psound", "esc", "pauseid", "sumtitle", "sumscore", "sumyou", "sumboard", "sumsaved", "again", "leave", "playing", "mapname", "share", "fps", "full", "support", "supportline", "copysupport"];
169169
170170 /** The ones the server renders with `hidden` on them: the page starts at the menu. */
171171 const HIDDEN = ["pause", "summary", "buy", "touch", "full"];
src/pages/game.test.ts +16
@@ -18,6 +18,7 @@
1818 * `digits.test-helper.ts` is that rule, shared with `/range` and `/example`.
1919 */
2020
21 import { SENS } from "../client/settings.ts";
2122 import { expect, test } from "bun:test";
2223 import { mkdtempSync } from "node:fs";
2324 import { tmpdir } from "node:os";
@@ -116,6 +117,21 @@ test("the menu has a Motion control, its first option the game's default for the
116117 );
117118 });
118119
120 test("#401: the pause panel carries the settings, with the menu's range, once each", () => {
121 const html = gamePage(db(), options);
122 const pauseAt = html.indexOf(`<div class="overlay" id="pause"`);
123 const summaryAt = html.indexOf(`<div class="overlay" id="summary"`);
124 for (const id of ["psens", "ptouchsens", "pmotion", "psound"]) {
125 expect(html.split(`id="${id}"`).length - 1).toBe(1);
126 expect(html.indexOf(`id="${id}"`)).toBeGreaterThan(pauseAt);
127 expect(html.indexOf(`id="${id}"`)).toBeLessThan(summaryAt);
128 }
129 const range = `type="number" min="${SENS.min}" max="${SENS.max}" step="${SENS.step}" value="${SENS.start}"`;
130 expect(html).toContain(`<input id="sens" ${range}`);
131 expect(html).toContain(`<input id="psens" ${range}`);
132 expect(html).toContain(`<select id="psound"><option value="on">On</option><option value="off">Off</option></select>`);
133 });
134
119135 test("a recent match names the map it was played on, and the name is a link to it", () => {
120136 const home = db();
121137 const player: Player = { userId: "u-1", session: "s1", name: "kestrel" };
src/pages/game.ts +25 −5
@@ -23,6 +23,7 @@ import { COMPETITIVE, SHORT } from "../client/match.ts";
2323 import { DEFAULT_MOTION, MOTION_LABELS, MOTIONS } from "../client/motion.ts";
2424 import { CS_MOVE } from "../client/movement.ts";
2525 import { NOTHING_POSTED } from "../client/pending.ts";
26 import { SENS, SOUND_LABELS, SOUNDS } from "../client/settings.ts";
2627 import { FLASH, HE, SMOKE } from "../client/weapons.ts";
2728 import { CARD, type Card } from "../client/weights.ts";
2829 import { mapFrom, mapLink, type MapChoice } from "../mapname.ts";
@@ -369,11 +370,7 @@ const menu = (visitor: Who, asked: Asked, daily: Daily): string => `
369370 <label>Map<select id="map"><option value="de_forge">de_forge</option><option value="generated"${
370371 asked.map === "generated" ? " selected" : ""
371372 }>generated — a new one every match</option></select></label>
372 <label>Sensitivity<input id="sens" type="number" min="0.2" max="12" step="0.1" value="2.2"></label>
373 <label class="thumbonly">Touch sens<input id="touchsens" type="number" min="0.2" max="12" step="0.1"
374 value="2.2"></label>
375 <label>Motion<select id="motion">${[DEFAULT_MOTION, ...MOTIONS.filter((m) => m !== DEFAULT_MOTION)].map((m) =>
376 `<option value="${m}">${escape(MOTION_LABELS[m])}</option>`).join("")}</select></label>
373 ${settingsHtml("")}
377374 </div>
378375 <div class="row">
379376 <label>Match id<input id="matchid" maxlength="${LONGEST_PASTE}" placeholder="paste an id or a link to play it again"
@@ -498,6 +495,25 @@ export const planHtml = (planning: boolean, plans?: Readout): string =>
498495 }</p>${plans === undefined ? "" : `
499496 <p class="hint" id="planned">${escape(plannedSaid(plans))}</p>`}`;
500497
498 /** A `<select>`'s options, the default first, each label escaped. */
499 const choices = <T extends string>(values: readonly T[], first: T, labels: Record<T, string>): string =>
500 [first, ...values.filter((v) => v !== first)].map((v) => `<option value="${v}">${escape(labels[v])}</option>`).join("");
501
502 /**
503 * The settings row: how the game feels rather than which match it is, from
504 * `src/client/settings.ts`. It is rendered twice — in the menu with bare ids and
505 * in the pause panel with `p` in front of each — because a sensitivity that is
506 * wrong is found out mid-round (#401), and `src/client/main.ts` keeps the two
507 * copies saying the same thing. The range is the module's, not typed here.
508 */
509 const settingsHtml = (prefix: "" | "p"): string => {
510 const range = `type="number" min="${SENS.min}" max="${SENS.max}" step="${SENS.step}" value="${SENS.start}" inputmode="decimal"`;
511 return ` <label>Sensitivity<input id="${prefix}sens" ${range}></label>
512 <label class="thumbonly">Touch sens<input id="${prefix}touchsens" ${range}></label>
513 <label>Motion<select id="${prefix}motion">${choices(MOTIONS, DEFAULT_MOTION, MOTION_LABELS)}</select></label>
514 <label>Sound<select id="${prefix}sound">${choices(SOUNDS, "on", SOUND_LABELS)}</select></label>`;
515 };
516
501517 /**
502518 * The pause panel. Its line about Escape is rendered with the sentence that is
503519 * true before any key has been pressed, and `src/client/main.ts` replaces it on
@@ -524,6 +540,10 @@ const BUY = `
524540 const PAUSE = `
525541 <h2>Paused</h2>
526542 <div class="row"><button type="button" class="primary" id="resume">Resume</button><button type="button" id="quit">Leave match</button></div>
543 <div class="row">
544 ${settingsHtml("p")}
545 </div>
546 <p class="hint">These apply as you change them, here or in the menu, and are remembered for your next visit.</p>
527547 <p class="hint" id="esc"><kbd>Esc</kbd> or <kbd>P</kbd> pauses, and Resume goes back.</p>
528548 <p class="hint" id="pauseid"></p>
529549 `;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs