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
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/401-cant-change-sens-mid-game-this-is-annoyi → main 1 commit fork
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 | ||
| 575 | 575 | already asks: the page reads `(prefers-reduced-motion: reduce)` through |
| 576 | 576 | `matchMedia` once at boot and starts at **Reduced** if the system says so; |
| 577 | 577 | **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 | |
| 580 | 579 | walk's sway on the gun in your hands (it goes to zero, the gun a player |
| 581 | 580 | standing still already holds), and the fade on the red arc that says where a |
| 582 | 581 | 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 | ||
| 587 | 586 | holds the two values and the line between them; `scene` and `hud` take the one |
| 588 | 587 | value with the frame. |
| 589 | 588 | |
| 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 | ||
| 590 | 601 | **Windows is asked for the mouse the way Windows needs.** `Controls.lock()` asks |
| 591 | 602 | for `unadjustedMovement`, because without it Chromium on Windows hands the page |
| 592 | 603 | 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", () => { | ||
| 388 | 388 | expect(five.mouse).toBeCloseTo(-0.484, 9); |
| 389 | 389 | }); |
| 390 | 390 | |
| 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 | ||
| 391 | 460 | test("the whole mouse plays the game: the wheel jumps and the thumb buttons do not navigate", () => { |
| 392 | 461 | const stage = playing(); |
| 393 | 462 | stage.lock(true); |
src/client/main.ts +67 −8
| @@ -89,6 +89,7 @@ import type { Level } from "./level.ts"; | ||
| 89 | 89 | import { FOV, fovFor, lookAt } from "./look.ts"; |
| 90 | 90 | import { COMPETITIVE, SHORT, type Rules, type Team } from "./match.ts"; |
| 91 | 91 | 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"; | |
| 92 | 93 | import { LAYERS, MATERIALS, TEX_SIZE } from "./materials.ts"; |
| 93 | 94 | import { meshFor } from "./models.ts"; |
| 94 | 95 | import { parsePacked } from "./packed.ts"; |
| @@ -395,6 +396,7 @@ export function boot(host: Host): Frame { | ||
| 395 | 396 | const motionField = by<Field>("motion"); |
| 396 | 397 | motionField.value = motionFor(win.matchMedia(REDUCED_MOTION_QUERY).matches); |
| 397 | 398 | let motion: Motion = parseMotion(motionField.value); |
| 399 | const soundField = by<Field>("sound"); | |
| 398 | 400 | const mapField = by<Field>("map"); |
| 399 | 401 | // The id a person pasted, the line that answers them, and the three places |
| 400 | 402 | // the match being played says what it is: the section under the stage (the |
| @@ -523,10 +525,63 @@ export function boot(host: Host): Frame { | ||
| 523 | 525 | const controls = createControls( |
| 524 | 526 | { surface: sceneCanvas, window: win, document: doc }, |
| 525 | 527 | { |
| 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, | |
| 528 | 529 | }, |
| 529 | 530 | ); |
| 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(); | |
| 530 | 585 | attachButtons(doc.querySelectorAll("[data-action]"), controls, { tap: CS_TAP }); |
| 531 | 586 | |
| 532 | 587 | // 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 { | ||
| 1241 | 1296 | posted = -1; |
| 1242 | 1297 | postedFinal = false; |
| 1243 | 1298 | 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(); | |
| 1247 | 1304 | running = true; |
| 1248 | 1305 | lastSound = 0; |
| 1249 | 1306 | show(menu, false); |
| @@ -1306,9 +1363,11 @@ export function boot(host: Host): Frame { | ||
| 1306 | 1363 | roomBuy = null; |
| 1307 | 1364 | posted = -1; |
| 1308 | 1365 | 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(); | |
| 1312 | 1371 | running = true; |
| 1313 | 1372 | lastSound = 0; |
| 1314 | 1373 | 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 = { | ||
| 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", "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"]; | |
| 169 | 169 | |
| 170 | 170 | /** The ones the server renders with `hidden` on them: the page starts at the menu. */ |
| 171 | 171 | const HIDDEN = ["pause", "summary", "buy", "touch", "full"]; |
src/pages/game.test.ts +16
| @@ -18,6 +18,7 @@ | ||
| 18 | 18 | * `digits.test-helper.ts` is that rule, shared with `/range` and `/example`. |
| 19 | 19 | */ |
| 20 | 20 | |
| 21 | import { SENS } from "../client/settings.ts"; | |
| 21 | 22 | import { expect, test } from "bun:test"; |
| 22 | 23 | import { mkdtempSync } from "node:fs"; |
| 23 | 24 | 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 | ||
| 116 | 117 | ); |
| 117 | 118 | }); |
| 118 | 119 | |
| 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 | ||
| 119 | 135 | test("a recent match names the map it was played on, and the name is a link to it", () => { |
| 120 | 136 | const home = db(); |
| 121 | 137 | 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"; | ||
| 23 | 23 | import { DEFAULT_MOTION, MOTION_LABELS, MOTIONS } from "../client/motion.ts"; |
| 24 | 24 | import { CS_MOVE } from "../client/movement.ts"; |
| 25 | 25 | import { NOTHING_POSTED } from "../client/pending.ts"; |
| 26 | import { SENS, SOUND_LABELS, SOUNDS } from "../client/settings.ts"; | |
| 26 | 27 | import { FLASH, HE, SMOKE } from "../client/weapons.ts"; |
| 27 | 28 | import { CARD, type Card } from "../client/weights.ts"; |
| 28 | 29 | import { mapFrom, mapLink, type MapChoice } from "../mapname.ts"; |
| @@ -369,11 +370,7 @@ const menu = (visitor: Who, asked: Asked, daily: Daily): string => ` | ||
| 369 | 370 | <label>Map<select id="map"><option value="de_forge">de_forge</option><option value="generated"${ |
| 370 | 371 | asked.map === "generated" ? " selected" : "" |
| 371 | 372 | }>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("")} | |
| 377 | 374 | </div> |
| 378 | 375 | <div class="row"> |
| 379 | 376 | <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 => | ||
| 498 | 495 | }</p>${plans === undefined ? "" : ` |
| 499 | 496 | <p class="hint" id="planned">${escape(plannedSaid(plans))}</p>`}`; |
| 500 | 497 | |
| 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 | ||
| 501 | 517 | /** |
| 502 | 518 | * The pause panel. Its line about Escape is rendered with the sentence that is |
| 503 | 519 | * true before any key has been pressed, and `src/client/main.ts` replaces it on |
| @@ -524,6 +540,10 @@ const BUY = ` | ||
| 524 | 540 | const PAUSE = ` |
| 525 | 541 | <h2>Paused</h2> |
| 526 | 542 | <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> | |
| 527 | 547 | <p class="hint" id="esc"><kbd>Esc</kbd> or <kbd>P</kbd> pauses, and Resume goes back.</p> |
| 528 | 548 | <p class="hint" id="pauseid"></p> |
| 529 | 549 | `; |