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

Merged opened this

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

Merged

scout/cs:scout/180-the-menu-remembers-your-map-and-your-dif → main 1 commit fork

  • Merged. scout/180-the-menu-remembers-your-map-and-your-dif was merged into main as 48b4494.

Review

Sign in to review this change.
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
212212 out the HE grenade if you bought one (fire throws it, and the hand goes back to
213213 the gun once it is gone), <kbd>Z</kbd> opens the radio, <kbd>Tab</kbd> shows
214214 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.
219221
220222 **A gun has to come up before it fires.** Every weapon has a draw — the knife
221223 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";
1818 import { ACTIONS, attachButtons, createControls, DEFAULT_BINDINGS, type Action } from "./input.ts";
1919 import { advance, TICK, waiting } from "./loop.ts";
2020 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";
2222 import type { MapName } from "./map.ts";
2323 import { MOTIONS, type Motion } from "./motion.ts";
2424 import { MAX_SCALE, nextRenderScale } from "./quality.ts";
2525 import { createRenderer, type Camera } from "./renderer.ts";
2626 import type { CrosshairStyle } from "./crosshair.ts";
2727 import {
28 DEFAULT_SETTINGS, loadSettings, parseCrosshair, saveSettings, SENSITIVITY_MAX, SENSITIVITY_MIN,
28 DEFAULT_SETTINGS, loadSettings, NAME_MAX, parseCrosshair, saveSettings, SENSITIVITY_MAX, SENSITIVITY_MIN,
2929 ZOOM_RATIO_MAX, ZOOM_RATIO_MIN,
3030 } from "./settings.ts";
3131 import { fovFor } from "./zoom.ts";
@@ -178,6 +178,28 @@ function boot(): void {
178178 mapField.value = next;
179179 saveSettings(settings);
180180 };
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 };
181203 // Motion, paired the way difficulty is and live the moment it changes: the
182204 // renderer is handed the value once, here, and keeps a number rather than
183205 // asking the question sixty times a second; the HUD is handed it with the
@@ -205,6 +227,9 @@ function boot(): void {
205227 difficultyField.value = settings.difficulty;
206228 pauseDifficultyField.value = settings.difficulty;
207229 mapField.value = settings.map;
230 nameField.value = settings.name;
231 lengthField.value = settings.matchLength;
232 sideField.value = settings.side;
208233 // Whatever storage said, or the system's own answer on a first run: both
209234 // fields and the renderer start where the setting actually is.
210235 motionField.value = settings.motion;
@@ -222,6 +247,9 @@ function boot(): void {
222247 difficultyField.addEventListener("change", () => syncDifficulty(difficultyField.value));
223248 pauseDifficultyField.addEventListener("change", () => syncDifficulty(pauseDifficultyField.value));
224249 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));
225253 motionField.addEventListener("change", () => syncMotion(motionField.value));
226254 pauseMotionField.addEventListener("change", () => syncMotion(pauseMotionField.value));
227255 volumeField.addEventListener("input", () => {
@@ -730,15 +758,18 @@ function boot(): void {
730758
731759 function start(): void {
732760 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));
737769 // Minted before the snapshot is written, and only here: a saved match is
738770 // never discarded by merely showing the menu, only by a new one actually
739771 // starting.
740772 matchId = crypto.randomUUID();
741 localStorage.setItem("cs.name", name);
742773 enterMatch();
743774 }
744775
@@ -801,9 +832,6 @@ function boot(): void {
801832 if (!buyPanel.hidden) openBuy(false);
802833 else if (!radioPanel.hidden) openRadio(false);
803834 });
804 const saved = localStorage.getItem("cs.name");
805 if (saved !== null && nameField.value === "") nameField.value = saved;
806
807835 // --- sound and flinch for whatever happened in the ticks we just ran
808836 function feelEffects(current: World, ears: Player): void {
809837 // Sound comes from where the camera is: while dead that is a teammate's
src/client/match.ts +6
@@ -6,6 +6,7 @@
66 */
77
88 export type Team = "t" | "ct";
9 export const SIDES: readonly Team[] = ["ct", "t"];
910 export const other = (team: Team): Team => (team === "t" ? "ct" : "t");
1011
1112 export type Phase = "freeze" | "live" | "planted" | "over" | "match_over";
@@ -41,6 +42,11 @@ export const COMPETITIVE: Rules = {
4142 /** The same rules over five rounds, for a match that fits in a coffee break. */
4243 export const SHORT: Rules = { ...COMPETITIVE, winScore: 5, freezeTime: 8 };
4344
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
4450 export type MatchState = {
4551 rules: Rules;
4652 round: number;
src/client/settings.test.ts +71
@@ -35,11 +35,13 @@ test("out-of-range numbers are clamped, not rejected", () => {
3535 muted: false, volume: 1, sensitivity: 12, touchSensitivity: DEFAULT_SETTINGS.touchSensitivity,
3636 zoomRatio: 1, difficulty: "normal", map: "de_forge",
3737 motion: "full", crosshair: DEFAULT_CROSSHAIR, bindings: DEFAULT_BINDINGS,
38 name: "", matchLength: "short", side: "ct",
3839 });
3940 expect(parseSettings('{"volume":-3,"sensitivity":-1,"zoomRatio":-1}')).toEqual({
4041 muted: false, volume: 0, sensitivity: 0.2, touchSensitivity: DEFAULT_SETTINGS.touchSensitivity,
4142 zoomRatio: 0.1, difficulty: "normal", map: "de_forge",
4243 motion: "full", crosshair: DEFAULT_CROSSHAIR, bindings: DEFAULT_BINDINGS,
44 name: "", matchLength: "short", side: "ct",
4345 });
4446 });
4547
@@ -60,6 +62,7 @@ test("a whole, valid settings object round-trips", () => {
6062 const stored: Settings = {
6163 muted: true, volume: 0.4, sensitivity: 3.5, touchSensitivity: 4.1, zoomRatio: 0.5, difficulty: "hard",
6264 map: "de_forge", motion: "reduced", crosshair, bindings,
65 name: "Ana", matchLength: "long", side: "t",
6366 };
6467 expect(parseSettings(JSON.stringify(stored))).toEqual(stored);
6568 });
@@ -202,6 +205,74 @@ test("every map in the catalogue survives being stored and read back", () => {
202205 expect(MAP_NAMES.length).toBeGreaterThan(1);
203206 });
204207
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
205276 test("no stored bindings at all is precisely today's map", () => {
206277 expect(parseSettings(null).bindings).toEqual(DEFAULT_BINDINGS);
207278 expect(parseSettings('{"muted":true}').bindings).toEqual(DEFAULT_BINDINGS);
src/client/settings.ts +27 −2
@@ -1,7 +1,8 @@
11 /**
22 * 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.
56 * `parseSettings` is the boundary — a missing, malformed, out-of-range or
67 * unknown value falls back to the default and never puts NaN, an
78 * unrecognised string or an unbound action into the game. What a difficulty
@@ -16,9 +17,13 @@ import {
1617 } from "./crosshair.ts";
1718 import { ACTIONS, DEFAULT_BINDINGS, type Action } from "./input.ts";
1819 import { DEFAULT_MAP, MAP_NAMES, type MapName } from "./map.ts";
20 import { MATCH_LENGTHS, SIDES, type MatchLength, type Team } from "./match.ts";
1921 import { DEFAULT_MOTION, MOTIONS, preferredMotion, type Motion } from "./motion.ts";
2022 import { DEFAULT_ZOOM_RATIO, ZOOM_RATIO_MAX, ZOOM_RATIO_MIN } from "./zoom.ts";
2123
24 /** The same cap the `name` input carries as its `maxlength`. */
25 export const NAME_MAX = 24;
26
2227 export type Settings = {
2328 muted: boolean;
2429 volume: number;
@@ -33,6 +38,10 @@ export type Settings = {
3338 motion: Motion;
3439 crosshair: CrosshairStyle;
3540 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;
3645 };
3746
3847 export const DEFAULT_SETTINGS: Settings = {
@@ -42,6 +51,9 @@ export const DEFAULT_SETTINGS: Settings = {
4251 difficulty: DEFAULT_DIFFICULTY, map: DEFAULT_MAP, motion: DEFAULT_MOTION,
4352 crosshair: DEFAULT_CROSSHAIR,
4453 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",
4557 };
4658 export const SENSITIVITY_MIN = 0.2;
4759 export const SENSITIVITY_MAX = 12;
@@ -58,6 +70,16 @@ const isMapName = (value: unknown): value is MapName =>
5870 const isMotion = (value: unknown): value is Motion =>
5971 typeof value === "string" && (MOTIONS as readonly string[]).includes(value);
6072
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
6183 /**
6284 * Every action ends up with a code, and no two actions ever end up with the
6385 * 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
158180 motion: isMotion(obj.motion) ? obj.motion : unchosen,
159181 crosshair: parseCrosshair(obj.crosshair),
160182 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,
161186 };
162187 }
163188

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs