Split The code is public; the app answers only to the crew.
The menu remembers your map and your difficulty but forgets your name, your side and the match length #230
Check · Review · Land · Live done — Landed and live in production.
scout/cs:scout/180-the-menu-remembers-your-map-and-your-dif → main 1 commit fork
5 files
changed +148
−16
README.md +6 −4
| @@ -212,10 +212,12 @@ time, <kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> switch guns and <kbd>4</kbd> takes | ||
| 212 | 212 | out the HE grenade if you bought one (fire throws it, and the hand goes back to |
| 213 | 213 | the gun once it is gone), <kbd>Z</kbd> opens the radio, <kbd>Tab</kbd> shows |
| 214 | 214 | the scoreboard, <kbd>Esc</kbd> pauses the match on a desktop and `menu` on the |
| 215 | pad does it on a phone. Pausing also opens mute, volume, | |
| 216 | look sensitivity and the scope's own share of it — and on a phone, the thumb's | |
| 217 | own sensitivity too — which take effect at once and are remembered for next | |
| 218 | time. | |
| 215 | pad does it on a phone. The menu's **Name**, **Match** length and **Side** | |
| 216 | are remembered for next time too, the same as Map and Difficulty below — a | |
| 217 | returning player presses Play instead of filling the form in again. Pausing | |
| 218 | also opens mute, volume, look sensitivity and the scope's own share of it — | |
| 219 | and on a phone, the thumb's own sensitivity too — which take effect at once | |
| 220 | and are remembered for next time. | |
| 219 | 221 | |
| 220 | 222 | **A gun has to come up before it fires.** Every weapon has a draw — the knife |
| 221 | 223 | 0.4 s, a pistol 0.5–0.6 s, a rifle 1 s, the AWP 1.25 s — and for that long |
src/client/main.ts +38 −10
| @@ -18,14 +18,14 @@ import { alphaOf } from "./interpolate.ts"; | ||
| 18 | 18 | import { ACTIONS, attachButtons, createControls, DEFAULT_BINDINGS, type Action } from "./input.ts"; |
| 19 | 19 | import { advance, TICK, waiting } from "./loop.ts"; |
| 20 | 20 | import { clamp } from "./math.ts"; |
| 21 | import { COMPETITIVE, SHORT, type Rules } from "./match.ts"; | |
| 21 | import { MATCH_LENGTHS, RULES, SIDES, type MatchLength, type Rules, type Team } from "./match.ts"; | |
| 22 | 22 | import type { MapName } from "./map.ts"; |
| 23 | 23 | import { MOTIONS, type Motion } from "./motion.ts"; |
| 24 | 24 | import { MAX_SCALE, nextRenderScale } from "./quality.ts"; |
| 25 | 25 | import { createRenderer, type Camera } from "./renderer.ts"; |
| 26 | 26 | import type { CrosshairStyle } from "./crosshair.ts"; |
| 27 | 27 | import { |
| 28 | DEFAULT_SETTINGS, loadSettings, parseCrosshair, saveSettings, SENSITIVITY_MAX, SENSITIVITY_MIN, | |
| 28 | DEFAULT_SETTINGS, loadSettings, NAME_MAX, parseCrosshair, saveSettings, SENSITIVITY_MAX, SENSITIVITY_MIN, | |
| 29 | 29 | ZOOM_RATIO_MAX, ZOOM_RATIO_MIN, |
| 30 | 30 | } from "./settings.ts"; |
| 31 | 31 | import { fovFor } from "./zoom.ts"; |
| @@ -178,6 +178,28 @@ function boot(): void { | ||
| 178 | 178 | mapField.value = next; |
| 179 | 179 | saveSettings(settings); |
| 180 | 180 | }; |
| 181 | // Name, match length and side: chosen once, in the menu, the same as the | |
| 182 | // map — there is nowhere mid-match any of the three can take hold, so | |
| 183 | // there is no pause-overlay twin for any of them either. | |
| 184 | const syncName = (value: string): void => { | |
| 185 | const next = value.trim().slice(0, NAME_MAX); | |
| 186 | settings = { ...settings, name: next }; | |
| 187 | nameField.value = next; | |
| 188 | saveSettings(settings); | |
| 189 | }; | |
| 190 | const syncMatchLength = (value: string): void => { | |
| 191 | const next: MatchLength = (MATCH_LENGTHS as readonly string[]).includes(value) | |
| 192 | ? (value as MatchLength) : DEFAULT_SETTINGS.matchLength; | |
| 193 | settings = { ...settings, matchLength: next }; | |
| 194 | lengthField.value = next; | |
| 195 | saveSettings(settings); | |
| 196 | }; | |
| 197 | const syncSide = (value: string): void => { | |
| 198 | const next: Team = (SIDES as readonly string[]).includes(value) ? (value as Team) : DEFAULT_SETTINGS.side; | |
| 199 | settings = { ...settings, side: next }; | |
| 200 | sideField.value = next; | |
| 201 | saveSettings(settings); | |
| 202 | }; | |
| 181 | 203 | // Motion, paired the way difficulty is and live the moment it changes: the |
| 182 | 204 | // renderer is handed the value once, here, and keeps a number rather than |
| 183 | 205 | // asking the question sixty times a second; the HUD is handed it with the |
| @@ -205,6 +227,9 @@ function boot(): void { | ||
| 205 | 227 | difficultyField.value = settings.difficulty; |
| 206 | 228 | pauseDifficultyField.value = settings.difficulty; |
| 207 | 229 | mapField.value = settings.map; |
| 230 | nameField.value = settings.name; | |
| 231 | lengthField.value = settings.matchLength; | |
| 232 | sideField.value = settings.side; | |
| 208 | 233 | // Whatever storage said, or the system's own answer on a first run: both |
| 209 | 234 | // fields and the renderer start where the setting actually is. |
| 210 | 235 | motionField.value = settings.motion; |
| @@ -222,6 +247,9 @@ function boot(): void { | ||
| 222 | 247 | difficultyField.addEventListener("change", () => syncDifficulty(difficultyField.value)); |
| 223 | 248 | pauseDifficultyField.addEventListener("change", () => syncDifficulty(pauseDifficultyField.value)); |
| 224 | 249 | mapField.addEventListener("change", () => syncMap(mapField.value)); |
| 250 | nameField.addEventListener("change", () => syncName(nameField.value)); | |
| 251 | lengthField.addEventListener("change", () => syncMatchLength(lengthField.value)); | |
| 252 | sideField.addEventListener("change", () => syncSide(sideField.value)); | |
| 225 | 253 | motionField.addEventListener("change", () => syncMotion(motionField.value)); |
| 226 | 254 | pauseMotionField.addEventListener("change", () => syncMotion(pauseMotionField.value)); |
| 227 | 255 | volumeField.addEventListener("input", () => { |
| @@ -730,15 +758,18 @@ function boot(): void { | ||
| 730 | 758 | |
| 731 | 759 | function start(): void { |
| 732 | 760 | cancelCapture?.(); |
| 733 | const rules: Rules = lengthField.value === "long" ? COMPETITIVE : SHORT; | |
| 734 | const team = sideField.value === "t" ? "t" : "ct"; | |
| 735 | const name = nameField.value.trim().slice(0, 24) || "you"; | |
| 736 | world = createWorld(rules, name, team, 5, undefined, settings.difficulty, mapFor(settings.map)); | |
| 761 | // Synced here rather than only on "change", so a name typed and never | |
| 762 | // blurred — the field still focused when Play is pressed — is not lost. | |
| 763 | syncName(nameField.value); | |
| 764 | syncMatchLength(lengthField.value); | |
| 765 | syncSide(sideField.value); | |
| 766 | const rules: Rules = RULES[settings.matchLength]; | |
| 767 | const name = settings.name || "you"; | |
| 768 | world = createWorld(rules, name, settings.side, 5, undefined, settings.difficulty, mapFor(settings.map)); | |
| 737 | 769 | // Minted before the snapshot is written, and only here: a saved match is |
| 738 | 770 | // never discarded by merely showing the menu, only by a new one actually |
| 739 | 771 | // starting. |
| 740 | 772 | matchId = crypto.randomUUID(); |
| 741 | localStorage.setItem("cs.name", name); | |
| 742 | 773 | enterMatch(); |
| 743 | 774 | } |
| 744 | 775 | |
| @@ -801,9 +832,6 @@ function boot(): void { | ||
| 801 | 832 | if (!buyPanel.hidden) openBuy(false); |
| 802 | 833 | else if (!radioPanel.hidden) openRadio(false); |
| 803 | 834 | }); |
| 804 | const saved = localStorage.getItem("cs.name"); | |
| 805 | if (saved !== null && nameField.value === "") nameField.value = saved; | |
| 806 | ||
| 807 | 835 | // --- sound and flinch for whatever happened in the ticks we just ran |
| 808 | 836 | function feelEffects(current: World, ears: Player): void { |
| 809 | 837 | // Sound comes from where the camera is: while dead that is a teammate's |
src/client/match.ts +6
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | export type Team = "t" | "ct"; |
| 9 | export const SIDES: readonly Team[] = ["ct", "t"]; | |
| 9 | 10 | export const other = (team: Team): Team => (team === "t" ? "ct" : "t"); |
| 10 | 11 | |
| 11 | 12 | export type Phase = "freeze" | "live" | "planted" | "over" | "match_over"; |
| @@ -41,6 +42,11 @@ export const COMPETITIVE: Rules = { | ||
| 41 | 42 | /** The same rules over five rounds, for a match that fits in a coffee break. */ |
| 42 | 43 | export const SHORT: Rules = { ...COMPETITIVE, winScore: 5, freezeTime: 8 }; |
| 43 | 44 | |
| 45 | export type MatchLength = "short" | "long"; | |
| 46 | export const MATCH_LENGTHS: readonly MatchLength[] = ["short", "long"]; | |
| 47 | /** The rules a menu's Match select actually means, keyed the way it stores them. */ | |
| 48 | export const RULES: Record<MatchLength, Rules> = { short: SHORT, long: COMPETITIVE }; | |
| 49 | ||
| 44 | 50 | export type MatchState = { |
| 45 | 51 | rules: Rules; |
| 46 | 52 | round: number; |
src/client/settings.test.ts +71
| @@ -35,11 +35,13 @@ test("out-of-range numbers are clamped, not rejected", () => { | ||
| 35 | 35 | muted: false, volume: 1, sensitivity: 12, touchSensitivity: DEFAULT_SETTINGS.touchSensitivity, |
| 36 | 36 | zoomRatio: 1, difficulty: "normal", map: "de_forge", |
| 37 | 37 | motion: "full", crosshair: DEFAULT_CROSSHAIR, bindings: DEFAULT_BINDINGS, |
| 38 | name: "", matchLength: "short", side: "ct", | |
| 38 | 39 | }); |
| 39 | 40 | expect(parseSettings('{"volume":-3,"sensitivity":-1,"zoomRatio":-1}')).toEqual({ |
| 40 | 41 | muted: false, volume: 0, sensitivity: 0.2, touchSensitivity: DEFAULT_SETTINGS.touchSensitivity, |
| 41 | 42 | zoomRatio: 0.1, difficulty: "normal", map: "de_forge", |
| 42 | 43 | motion: "full", crosshair: DEFAULT_CROSSHAIR, bindings: DEFAULT_BINDINGS, |
| 44 | name: "", matchLength: "short", side: "ct", | |
| 43 | 45 | }); |
| 44 | 46 | }); |
| 45 | 47 | |
| @@ -60,6 +62,7 @@ test("a whole, valid settings object round-trips", () => { | ||
| 60 | 62 | const stored: Settings = { |
| 61 | 63 | muted: true, volume: 0.4, sensitivity: 3.5, touchSensitivity: 4.1, zoomRatio: 0.5, difficulty: "hard", |
| 62 | 64 | map: "de_forge", motion: "reduced", crosshair, bindings, |
| 65 | name: "Ana", matchLength: "long", side: "t", | |
| 63 | 66 | }; |
| 64 | 67 | expect(parseSettings(JSON.stringify(stored))).toEqual(stored); |
| 65 | 68 | }); |
| @@ -202,6 +205,74 @@ test("every map in the catalogue survives being stored and read back", () => { | ||
| 202 | 205 | expect(MAP_NAMES.length).toBeGreaterThan(1); |
| 203 | 206 | }); |
| 204 | 207 | |
| 208 | // --- name, match length and side: chosen once in the menu, guarded the way | |
| 209 | // difficulty and map already are. | |
| 210 | ||
| 211 | test("a missing name is the default placeholder behaviour, not a name", () => { | |
| 212 | expect(parseSettings('{"muted":true}').name).toBe(""); | |
| 213 | expect(parseSettings(null).name).toBe(""); | |
| 214 | }); | |
| 215 | ||
| 216 | test("a garbage name is the default", () => { | |
| 217 | expect(parseSettings('{"name":123}').name).toBe(""); | |
| 218 | expect(parseSettings('{"name":null}').name).toBe(""); | |
| 219 | expect(parseSettings('{"name":["Ana"]}').name).toBe(""); | |
| 220 | }); | |
| 221 | ||
| 222 | test("a name is trimmed and capped at maxlength, never longer or padded", () => { | |
| 223 | expect(parseSettings('{"name":" Ana "}').name).toBe("Ana"); | |
| 224 | expect(parseSettings(JSON.stringify({ name: "x".repeat(40) })).name).toBe("x".repeat(24)); | |
| 225 | }); | |
| 226 | ||
| 227 | test("an empty or all-whitespace stored name loads as the default, not as a name", () => { | |
| 228 | expect(parseSettings('{"name":""}').name).toBe(""); | |
| 229 | expect(parseSettings('{"name":" "}').name).toBe(""); | |
| 230 | }); | |
| 231 | ||
| 232 | test("a valid name round-trips exactly", () => { | |
| 233 | expect(parseSettings('{"name":"Ana"}').name).toBe("Ana"); | |
| 234 | }); | |
| 235 | ||
| 236 | test("a missing match length is the default", () => { | |
| 237 | expect(parseSettings('{"muted":true}').matchLength).toBe("short"); | |
| 238 | }); | |
| 239 | ||
| 240 | test("a garbage match length is the default", () => { | |
| 241 | expect(parseSettings('{"matchLength":123}').matchLength).toBe("short"); | |
| 242 | expect(parseSettings('{"matchLength":null}').matchLength).toBe("short"); | |
| 243 | expect(parseSettings('{"matchLength":"medium"}').matchLength).toBe("short"); | |
| 244 | }); | |
| 245 | ||
| 246 | test("both match lengths survive being stored and read back", () => { | |
| 247 | expect(parseSettings('{"matchLength":"short"}').matchLength).toBe("short"); | |
| 248 | expect(parseSettings('{"matchLength":"long"}').matchLength).toBe("long"); | |
| 249 | }); | |
| 250 | ||
| 251 | test("a missing side is the default", () => { | |
| 252 | expect(parseSettings('{"muted":true}').side).toBe("ct"); | |
| 253 | }); | |
| 254 | ||
| 255 | test("a garbage side is the default", () => { | |
| 256 | expect(parseSettings('{"side":123}').side).toBe("ct"); | |
| 257 | expect(parseSettings('{"side":null}').side).toBe("ct"); | |
| 258 | expect(parseSettings('{"side":"terrorist"}').side).toBe("ct"); | |
| 259 | }); | |
| 260 | ||
| 261 | test("both sides survive being stored and read back", () => { | |
| 262 | expect(parseSettings('{"side":"ct"}').side).toBe("ct"); | |
| 263 | expect(parseSettings('{"side":"t"}').side).toBe("t"); | |
| 264 | }); | |
| 265 | ||
| 266 | test("a settings blob written before name, match length and side existed still loads", () => { | |
| 267 | const old = '{"muted":true,"volume":0.4,"sensitivity":3.5,"difficulty":"hard","map":"de_cinder",' + | |
| 268 | `"crosshair":${JSON.stringify(DEFAULT_CROSSHAIR)},"bindings":${JSON.stringify(DEFAULT_BINDINGS)}}`; | |
| 269 | const parsed = parseSettings(old); | |
| 270 | expect(parsed.name).toBe(""); | |
| 271 | expect(parsed.matchLength).toBe("short"); | |
| 272 | expect(parsed.side).toBe("ct"); | |
| 273 | expect(parsed.map).toBe("de_cinder"); // and nothing else is disturbed by the new fields | |
| 274 | }); | |
| 275 | ||
| 205 | 276 | test("no stored bindings at all is precisely today's map", () => { |
| 206 | 277 | expect(parseSettings(null).bindings).toEqual(DEFAULT_BINDINGS); |
| 207 | 278 | expect(parseSettings('{"muted":true}').bindings).toEqual(DEFAULT_BINDINGS); |
src/client/settings.ts +27 −2
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Settings a player can reach mid-match: mute, volume, look sensitivity, the |
| 3 | * scope's share of it, bot difficulty, motion, the crosshair and key bindings. | |
| 4 | * Stored in localStorage beside `cs.name`. | |
| 3 | * scope's share of it, bot difficulty, motion, the crosshair and key | |
| 4 | * bindings — and the ones chosen once before a match starts: name, match | |
| 5 | * length, side and map, all in the same record. | |
| 5 | 6 | * `parseSettings` is the boundary — a missing, malformed, out-of-range or |
| 6 | 7 | * unknown value falls back to the default and never puts NaN, an |
| 7 | 8 | * unrecognised string or an unbound action into the game. What a difficulty |
| @@ -16,9 +17,13 @@ import { | ||
| 16 | 17 | } from "./crosshair.ts"; |
| 17 | 18 | import { ACTIONS, DEFAULT_BINDINGS, type Action } from "./input.ts"; |
| 18 | 19 | import { DEFAULT_MAP, MAP_NAMES, type MapName } from "./map.ts"; |
| 20 | import { MATCH_LENGTHS, SIDES, type MatchLength, type Team } from "./match.ts"; | |
| 19 | 21 | import { DEFAULT_MOTION, MOTIONS, preferredMotion, type Motion } from "./motion.ts"; |
| 20 | 22 | import { DEFAULT_ZOOM_RATIO, ZOOM_RATIO_MAX, ZOOM_RATIO_MIN } from "./zoom.ts"; |
| 21 | 23 | |
| 24 | /** The same cap the `name` input carries as its `maxlength`. */ | |
| 25 | export const NAME_MAX = 24; | |
| 26 | ||
| 22 | 27 | export type Settings = { |
| 23 | 28 | muted: boolean; |
| 24 | 29 | volume: number; |
| @@ -33,6 +38,10 @@ export type Settings = { | ||
| 33 | 38 | motion: Motion; |
| 34 | 39 | crosshair: CrosshairStyle; |
| 35 | 40 | bindings: Record<Action, string>; |
| 41 | /** What the match calls you. Trimmed and capped at `NAME_MAX`; never markup, never NaN. */ | |
| 42 | name: string; | |
| 43 | matchLength: MatchLength; | |
| 44 | side: Team; | |
| 36 | 45 | }; |
| 37 | 46 | |
| 38 | 47 | export const DEFAULT_SETTINGS: Settings = { |
| @@ -42,6 +51,9 @@ export const DEFAULT_SETTINGS: Settings = { | ||
| 42 | 51 | difficulty: DEFAULT_DIFFICULTY, map: DEFAULT_MAP, motion: DEFAULT_MOTION, |
| 43 | 52 | crosshair: DEFAULT_CROSSHAIR, |
| 44 | 53 | bindings: DEFAULT_BINDINGS, |
| 54 | // An empty name is not a name: the menu's own placeholder ("you") says so, | |
| 55 | // and `main.ts` falls back to it the same way a first-run player would. | |
| 56 | name: "", matchLength: "short", side: "ct", | |
| 45 | 57 | }; |
| 46 | 58 | export const SENSITIVITY_MIN = 0.2; |
| 47 | 59 | export const SENSITIVITY_MAX = 12; |
| @@ -58,6 +70,16 @@ const isMapName = (value: unknown): value is MapName => | ||
| 58 | 70 | const isMotion = (value: unknown): value is Motion => |
| 59 | 71 | typeof value === "string" && (MOTIONS as readonly string[]).includes(value); |
| 60 | 72 | |
| 73 | const isMatchLength = (value: unknown): value is MatchLength => | |
| 74 | typeof value === "string" && (MATCH_LENGTHS as readonly string[]).includes(value); | |
| 75 | ||
| 76 | const isSide = (value: unknown): value is Team => | |
| 77 | typeof value === "string" && (SIDES as readonly string[]).includes(value); | |
| 78 | ||
| 79 | /** Trimmed and capped, the way the field itself already caps a keystroke; anything not a string is no name at all. */ | |
| 80 | const sanitizeName = (value: unknown): string => | |
| 81 | typeof value === "string" ? value.trim().slice(0, NAME_MAX) : DEFAULT_SETTINGS.name; | |
| 82 | ||
| 61 | 83 | /** |
| 62 | 84 | * Every action ends up with a code, and no two actions ever end up with the |
| 63 | 85 | * same one. An action's stored code is kept if it is a non-empty string and |
| @@ -158,6 +180,9 @@ export function parseSettings(raw: string | null, unchosen: Motion = DEFAULT_MOT | ||
| 158 | 180 | motion: isMotion(obj.motion) ? obj.motion : unchosen, |
| 159 | 181 | crosshair: parseCrosshair(obj.crosshair), |
| 160 | 182 | bindings: parseBindings(obj.bindings), |
| 183 | name: sanitizeName(obj.name), | |
| 184 | matchLength: isMatchLength(obj.matchLength) ? obj.matchLength : DEFAULT_SETTINGS.matchLength, | |
| 185 | side: isSide(obj.side) ? obj.side : DEFAULT_SETTINGS.side, | |
| 161 | 186 | }; |
| 162 | 187 | } |
| 163 | 188 |