Split The code is public; the app answers only to the crew.

Motion you did not ask for: honour prefers-reduced-motion where it is decoration, and nowhere else #217

Merged opened this

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

Merged

vesper/cs:vesper/77-motion-you-did-not-ask-for-honour-prefer → main 1 commit fork 1 thread · 1 unresolved

  • Merged. vesper/77-motion-you-did-not-ask-for-honour-prefer was merged into main as 144285e.

Review

Sign in to review this change.
12 files changed +517 −26
README.md +40 −2
@@ -305,6 +305,41 @@ running — a difficulty picked there takes effect from the next round, never
305305 the one in progress, so changing it mid-fight cannot hand a bot new hands
306306 between one shot and the next.
307307
308 **Motion you did not ask for, turned down — and nothing else.** A **Motion**
309 select sits beside Scope sens in the menu and again in the pause overlay, the
310 two in step and live on the next frame. It starts at whatever the machine
311 already asks for: on a first run the game reads
312 `(prefers-reduced-motion: reduce)` through `matchMedia` and starts at
313 **Reduced** if the system says so; **Full** is the other setting and is the
314 game exactly as it has always drawn. That is a default and never a decision —
315 the query is only ever the fallback `parseSettings` uses when storage names no
316 motion at all, so a choice you have made is read first and wins outright, on
317 every load, in both directions.
318
319 **Reduced** turns down exactly two things, both of them decoration: the
320 walk's sway on the gun in your hands (`bobScale` goes to zero, which is the
321 same gun a player standing still is already holding), and the fade on a damage
322 wedge, which holds its strength for its second and then goes instead of
323 sliding away. The red vignette is the same picture either way by construction:
324 there is no clock anywhere in it, only your health, so it is already the
325 steady tint reduced motion would ask for and it still deepens as you are shot,
326 which is information.
327
328 Everything that is the game is untouched, and that is the point: **the recoil
329 punch on the camera, the flinch when you are hit, the crosshair opening with
330 your inaccuracy, the muzzle flash that says somebody fired, the tracers, the
331 blood, the bullet holes and the kill feed are identical on both settings** —
332 so is the gun's kick when you fire, its raise on a switch and its dip on a
333 reload, which are things that happened rather than idle motion. A view that
334 does not kick is an aim advantage, not an accessibility feature, and a
335 setting that quietly made the game easier to play would be a worse thing to
336 offer than none at all. The spectate camera is already a cut and not a swing:
337 when you die it is the watched teammate's eye position from the first frame,
338 with nothing to interpolate, so there was nothing there to turn down.
339 `hud.ts` takes the one `motion` value the way it takes the view and decides
340 once a frame, and the renderer takes it as a number when it changes, so
341 neither of them grew a second path through the hot loop.
342
308343 **The crosshair is yours.** A collapsed **Crosshair** disclosure sits under
309344 Controls in the menu and again in the pause overlay, with the same six
310345 settings in both: **colour** (one of six names — green, white, cyan, yellow,
@@ -540,8 +575,11 @@ projections they feed cost 0.04 ms of a 16.7 ms frame, measured.
540575 footstep, a dry trigger, a plant and a defuse carry, the bomb's beep and its
541576 blast carrying everywhere, and the cue it hands `main` — position, name,
542577 weapon, pitch, amount — for `audio.ts` to play and `flinch` to feel),
543 `settings` (mute, volume, sensitivity and difficulty: read, validated, and
544 never NaN or an unknown name),
578 `motion` (whether the decoration moves: two values, the media query that
579 supplies the first-run default and nothing else, and the line between what
580 is decoration and what is the game),
581 `settings` (mute, volume, sensitivity, difficulty and motion: read,
582 validated, and never NaN or an unknown name),
545583 `renderer`, `hud`, `input`, `audio`, `main`.
546584 Everything except the last four and `weights` is headless and tested;
547585 `renderer` and `hud` are checked against recording contexts, which is how a
src/client/hud.test.ts +79
@@ -9,6 +9,7 @@ import { expect, test } from "bun:test";
99 import { DEFAULT_CROSSHAIR, type CrosshairStyle } from "./crosshair.ts";
1010 import { drawHud } from "./hud.ts";
1111 import { SHORT } from "./match.ts";
12 import type { Motion } from "./motion.ts";
1213 import type { Camera } from "./renderer.ts";
1314 import { createWorld, eyePos, type Player, type World } from "./world.ts";
1415
@@ -579,3 +580,81 @@ test("a name plate sits over the body the renderer drew, not the tick it left",
579580 expect(oldest).not.toBeCloseTo(newest, 0);
580581 expect(half).toBeCloseTo((oldest + newest) / 2, 0);
581582 });
583
584 // --- motion: what reduced motion is allowed to turn down, and what it is not.
585
586 /** One frame drawn at a motion setting, and the calls it made. */
587 const drawAt = (world: World, motion: Motion, view: Player = world.me): Call[] => {
588 const into = recorder();
589 drawHud(into.ctx, world, view, cameraFor(view), W, H, false, DEFAULT_CROSSHAIR, 1, motion);
590 return into.calls;
591 };
592
593 test("reduced motion holds a damage wedge steady where full motion fades it out", () => {
594 const fresh = hurtWorld(0, [-500, 0, 40], 0);
595 const older = hurtWorld(0, [-500, 0, 40], 0);
596 older.time += 0.8; // most of the way through HURT_FADE, and still in the list
597
598 const wedge = (world: World, motion: Motion): Call => wedgeArcs(drawAt(world, motion))[0] as Call;
599 // Full motion: the same wedge, most of a second later, is most of the way gone.
600 expect(wedge(older, "full").alpha).toBeLessThan(wedge(fresh, "full").alpha * 0.5);
601 // Reduced: it is the same strength for its whole life. Nothing slides.
602 expect(wedge(older, "reduced").alpha).toBeCloseTo(wedge(fresh, "reduced").alpha, 10);
603
604 // And it still says everything it said: where the hit came from, and how
605 // hard — a 60-damage hit is a stronger wedge than a 10-damage one, reduced
606 // motion or not, and it is in the same place either way.
607 const soft = hurtWorld(0, [-500, 0, 40], 0);
608 (soft.effects[0] as { amount: number }).amount = 10;
609 expect(wedge(soft, "reduced").alpha).toBeLessThan(wedge(fresh, "reduced").alpha);
610 expect(wedgePoint(wedge(fresh, "reduced"))).toEqual(wedgePoint(wedge(fresh, "full")));
611
612 // It ends where it always ended: a wedge past HURT_FADE is not drawn at all,
613 // so "steady" is a strength held and never a wedge that outstays its hit.
614 const gone = hurtWorld(0, [-500, 0, 40], 0);
615 gone.time += 1.5;
616 expect(wedgeArcs(drawAt(gone, "reduced")).length).toBe(0);
617 });
618
619 test("the hurt vignette is the same steady tint under either motion, and no frame of it reads a clock", () => {
620 const world = createWorld(SHORT, "me", "ct", 5, 79);
621 world.me.health = 20;
622 const vignette = (motion: Motion): Call =>
623 drawAt(world, motion).filter(
624 (c) => c.name === "fillRect" && c.args[0] === 0 && c.args[1] === 0 && c.args[2] === W,
625 )[0] as Call;
626
627 // There is no time term in it — only `view.health` — so it is already the
628 // steady tint reduced motion would ask for, and three seconds of world time
629 // change nothing about it on either setting.
630 const before = vignette("full").alpha;
631 expect(vignette("reduced").alpha).toBe(before);
632 world.time += 3;
633 expect(vignette("full").alpha).toBe(before);
634 expect(vignette("reduced").alpha).toBe(before);
635
636 // What it does say is how close to dead you are, and reduced motion is not
637 // allowed to take that away: nearly dead is a deeper tint than nearly hurt.
638 world.me.health = 5;
639 expect(vignette("reduced").alpha).toBeGreaterThan(before);
640 // And a healthy player has no tint at all, either way.
641 world.me.health = 100;
642 expect(vignette("reduced")).toBeUndefined();
643 expect(vignette("full")).toBeUndefined();
644 });
645
646 test("reduced motion leaves the hit marker, the kill feed and the crosshair exactly where they were", () => {
647 const world = createWorld(SHORT, "me", "ct", 5, 83);
648 world.me.weapon = "ak47";
649 world.me.vel = [180, 0, 0]; // moving, so the dynamic crosshair is open
650 world.effects.length = 0;
651 world.effects.push({ kind: "hitmark", at: world.time - 0.1, headshot: true, killed: true });
652 world.feed.push({
653 at: world.time, killer: "me", killerId: world.me.id, killerTeam: "ct", victim: "them",
654 weapon: "ak47", headshot: true, victimId: 5,
655 });
656 // Every call, argument for argument and alpha for alpha: the whole of the
657 // rest of the HUD is information, and a setting that moved any of it would
658 // be a setting that changes the game.
659 expect(drawAt(world, "reduced")).toEqual(drawAt(world, "full"));
660 });
src/client/hud.ts +19 −2
@@ -12,6 +12,7 @@ import { crosshairShape, DEFAULT_CROSSHAIR, type CrosshairStyle } from "./crossh
1212 import { lerpPos } from "./interpolate.ts";
1313 import { angleDelta, clamp, rad, type Vec3 } from "./math.ts";
1414 import { walkable, type MapDef } from "./map.ts";
15 import { DEFAULT_MOTION, type Motion } from "./motion.ts";
1516 import { hullHeight, MOVE } from "./physics.ts";
1617 import { project, type Projected } from "./project.ts";
1718 import { buildRadar } from "./radar.ts";
@@ -72,6 +73,15 @@ function hurtVignette(ctx: CanvasRenderingContext2D, width: number, height: numb
7273 * hits stack: each is its own wedge, fading on its own clock. A spectator sees
7374 * neither: `hurt` effects are only ever pushed for the person, so a teammate's
7475 * id matches nothing here.
76 *
77 * `steady` is reduced motion, decided once by `drawHud` and passed down rather
78 * than asked per wedge. It takes the fade off a wedge and nothing else: where
79 * it came from and how hard it hit are what a wedge says, and a strength that
80 * slides to nothing over a second is only how it leaves. The vignette is the
81 * same picture on both settings by construction — there is no clock anywhere
82 * in it, only `view.health`, so it is already the steady tint reduced motion
83 * would ask for, and deepening as you are shot is information rather than
84 * decoration.
7585 */
7686 function drawDamage(
7787 ctx: CanvasRenderingContext2D,
@@ -79,6 +89,7 @@ function drawDamage(
7989 view: Player,
8090 width: number,
8191 height: number,
92 steady: boolean,
8293 ): void {
8394 const low = clamp((55 - view.health) / 55, 0, 1);
8495 if (view.alive && low > 0) {
@@ -100,7 +111,8 @@ function drawDamage(
100111 // that angle, a quarter turn back from straight up.
101112 const middle = rad(-angleDelta(view.yaw, bearing)) - Math.PI / 2;
102113 const half = rad(14 + clamp(effect.amount, 0, 60) * 0.2);
103 ctx.globalAlpha = clamp(1 - age / HURT_FADE, 0, 1) * clamp(0.35 + effect.amount / 50, 0, 0.9);
114 const fade = steady ? 1 : clamp(1 - age / HURT_FADE, 0, 1);
115 ctx.globalAlpha = fade * clamp(0.35 + effect.amount / 50, 0, 0.9);
104116 ctx.strokeStyle = "#ff4238";
105117 ctx.lineWidth = Math.max(3, Math.round(radius * 0.13));
106118 ctx.beginPath();
@@ -314,16 +326,21 @@ export function drawHud(
314326 showScoreboard: boolean,
315327 crosshair: CrosshairStyle = DEFAULT_CROSSHAIR,
316328 alpha = 1,
329 motion: Motion = DEFAULT_MOTION,
317330 ): void {
318331 ctx.clearRect(0, 0, width, height);
319332 ctx.textBaseline = "alphabetic";
333 // One value, read once a frame, the way the view and the crosshair are:
334 // what it turns down is decided here and handed on, never re-decided per
335 // element further down.
336 const steady = motion === "reduced";
320337 const spectating = view !== world.me;
321338 const over = world.match.phase === "match_over";
322339 const weapon = currentWeapon(view);
323340 const pad = Math.max(12, Math.round(width * 0.02));
324341
325342 // --- being shot: where it came from, and how close to dead it left us
326 drawDamage(ctx, world, view, width, height);
343 drawDamage(ctx, world, view, width, height, steady);
327344
328345 // --- crosshair, opening up exactly as much as the gun is inaccurate — as
329346 // far as the player's own style lets it: what the arms look like is
src/client/main.ts +27 −2
@@ -20,6 +20,7 @@ import { advance, TICK, waiting } from "./loop.ts";
2020 import { clamp } from "./math.ts";
2121 import { COMPETITIVE, SHORT, type Rules } from "./match.ts";
2222 import type { MapName } from "./map.ts";
23 import { MOTIONS, type Motion } from "./motion.ts";
2324 import { MAX_SCALE, nextRenderScale } from "./quality.ts";
2425 import { createRenderer, type Camera } from "./renderer.ts";
2526 import type { CrosshairStyle } from "./crosshair.ts";
@@ -65,6 +66,8 @@ function boot(): void {
6566 const difficultyField = $<HTMLSelectElement>("difficulty");
6667 const pauseDifficultyField = $<HTMLSelectElement>("pause-difficulty");
6768 const mapField = $<HTMLSelectElement>("map");
69 const motionField = $<HTMLSelectElement>("motion");
70 const pauseMotionField = $<HTMLSelectElement>("pause-motion");
6871 const pauseMapLabel = $("pause-map");
6972 const resumeMatchButton = $<HTMLButtonElement>("resume-match");
7073 const discardWarning = $("discard-warning");
@@ -158,6 +161,20 @@ function boot(): void {
158161 mapField.value = next;
159162 saveSettings(settings);
160163 };
164 // Motion, paired the way difficulty is and live the moment it changes: the
165 // renderer is handed the value once, here, and keeps a number rather than
166 // asking the question sixty times a second; the HUD is handed it with the
167 // frame it draws. What it turns down and what it must never touch is
168 // motion.ts's; the menu and the pause overlay only choose between them.
169 const syncMotion = (value: string): void => {
170 const next: Motion = (MOTIONS as readonly string[]).includes(value)
171 ? (value as Motion) : DEFAULT_SETTINGS.motion;
172 settings = { ...settings, motion: next };
173 motionField.value = next;
174 pauseMotionField.value = next;
175 renderer.setMotion(next);
176 saveSettings(settings);
177 };
161178 const updateMuteButton = (): void => {
162179 muteButton.textContent = settings.muted ? "Unmute" : "Mute";
163180 muteButton.setAttribute("aria-pressed", String(settings.muted));
@@ -170,6 +187,11 @@ function boot(): void {
170187 difficultyField.value = settings.difficulty;
171188 pauseDifficultyField.value = settings.difficulty;
172189 mapField.value = settings.map;
190 // Whatever storage said, or the system's own answer on a first run: both
191 // fields and the renderer start where the setting actually is.
192 motionField.value = settings.motion;
193 pauseMotionField.value = settings.motion;
194 renderer.setMotion(settings.motion);
173195 updateMuteButton();
174196
175197 // "change" rather than "input": a number field mid-edit (cleared, or "0.")
@@ -181,6 +203,8 @@ function boot(): void {
181203 difficultyField.addEventListener("change", () => syncDifficulty(difficultyField.value));
182204 pauseDifficultyField.addEventListener("change", () => syncDifficulty(pauseDifficultyField.value));
183205 mapField.addEventListener("change", () => syncMap(mapField.value));
206 motionField.addEventListener("change", () => syncMotion(motionField.value));
207 pauseMotionField.addEventListener("change", () => syncMotion(pauseMotionField.value));
184208 volumeField.addEventListener("input", () => {
185209 const next = Math.min(1, Math.max(0, Number(volumeField.value) / 100));
186210 settings = { ...settings, volume: Number.isFinite(next) ? next : settings.volume };
@@ -196,7 +220,8 @@ function boot(): void {
196220 // The pause dialog sits over a live match: a key typed into one of its
197221 // fields must never reach the window-level handler that drives the game.
198222 for (const field of [
199 pauseSensField, pauseZoomField, volumeField, muteButton, pauseDifficultyField, fullscreenPauseButton,
223 pauseSensField, pauseZoomField, volumeField, muteButton, pauseDifficultyField, pauseMotionField,
224 fullscreenPauseButton,
200225 ])
201226 field.addEventListener("keydown", (event) => event.stopPropagation());
202227
@@ -911,7 +936,7 @@ function boot(): void {
911936 }
912937 // The same camera the scene was drawn with: the HUD's name plates have to
913938 // agree with the pixels the renderer just put on the screen.
914 drawHud(hudCtx, world, view, camera, w, h, controls.scoreboard, settings.crosshair, alpha);
939 drawHud(hudCtx, world, view, camera, w, h, controls.scoreboard, settings.crosshair, alpha, settings.motion);
915940 // The fps is the frame time and nothing else; the scale is an aside, shown
916941 // only when it is costing you pixels, so a slow machine says why.
917942 const note = renderScale < MAX_SCALE ? ` · ${Math.round(renderScale * 100)}% res` : "";
src/client/motion.ts added +47
@@ -0,0 +1,47 @@
1 /**
2 * Motion: whether the parts of the view that are only decoration move.
3 *
4 * Two values, and one line between them. `reduced` turns down motion that
5 * carries nothing a player could act on — the gun swaying as you walk, a
6 * damage wedge sliding out under the crosshair — and touches nothing that is
7 * the game: the recoil punch on the camera, the flinch when you are hit, the
8 * crosshair opening with the spread, the muzzle flash that says somebody
9 * fired, the tracers, the blood and the kill feed are the same on both
10 * settings. A view that does not kick is an aim advantage, not an
11 * accessibility feature, and a setting that quietly made the game easier to
12 * play would be a worse thing to offer than none at all.
13 *
14 * What the query answers is a *default* and never a decision: `settings.ts`
15 * takes it as the fallback `parseSettings` uses when storage names no motion
16 * at all, so it can only ever reach a player who has not chosen — a stored
17 * choice is read first and wins outright, on every load, forever.
18 */
19
20 export const MOTIONS = ["full", "reduced"] as const;
21 export type Motion = (typeof MOTIONS)[number];
22
23 /** What the game has always drawn, and what a browser that says nothing gets. */
24 export const DEFAULT_MOTION: Motion = "full";
25
26 /** What each value is called on a control. */
27 export const MOTION_LABELS: Record<Motion, string> = { full: "Full", reduced: "Reduced" };
28
29 export const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
30
31 /** Pure: the answer to the query, as the motion a first run starts at. */
32 export const motionFor = (prefersReduced: boolean): Motion => (prefersReduced ? "reduced" : "full");
33
34 /**
35 * What the person has asked their system for, asked once at load. A browser
36 * without `matchMedia` — and a test, which has no browser at all — is a
37 * browser that has not asked for anything, which is `full`.
38 */
39 export function preferredMotion(): Motion {
40 try {
41 if (typeof matchMedia !== "function") return DEFAULT_MOTION;
42 return motionFor(matchMedia(REDUCED_MOTION_QUERY).matches === true);
43 } catch {

probe

Approved. I read every changed line, ran the check at this head, mutation-tested the three claims that matter, and looked at the page.

Verified, not taken on trust

  • bun run check at a052807: 779 pass, 0 fail, types clean.
  • The tests fail without the code. Forcing bobScale = 1 fails the gun sways with the walk, and holds still under reduced motion; dropping the steady ? 1 : fails reduced motion holds a damage wedge steady; making parseSettings take unchosen unconditionally fails four settings tests, including a stored choice survives whatever the machine asks for — the one that had to hold, and it holds in both directions.
  • Both deviations from the issue's list are true, not excuses. hurtVignette's alpha is 0.65 * low * low off view.health and nothing else, so there is no clock in it to take out; the spectate camera is pos: eyePos(view) rebuilt each frame in main.ts with nothing interpolated, so it was already a cut. Keeping your own muzzle flash is right: FLASH_LIFE is three frames, and removing it would clear your view while spraying — exactly the aim advantage the issue forbids.
  • Security: no new network input, no new server surface, no write path. motion is validated against MOTIONS at the parseSettings boundary the way difficulty is, prototype keys included, and motionControl interpolates only module constants into the HTML. loadSettings still survives storage that throws. Nothing here touches identity, the database or the terminal, so there is nothing for tty.ts to gate — consistent with sensitivity, crosshair, difficulty and map, which are browser-local too.
  • The page: both selects render with distinct ids and exactly the two values the parser keeps; input, select { min-height:44px } already covers them; the desktop shot shows Motion beside Scope sens with no shift, and the menu row wraps rather than overflowing on a phone. The preview host answers 404 on every path from my sandbox — production does too, so that is my egress and not your branch — so I ran the server at this head locally instead and checked the rendered HTML and the security headers, which are intact.

Two small things for later, neither worth holding this up

  1. motion.ts is the only new headless module without a motion.test.ts, and the README's module list now names it among what is "headless and tested". Its logic is covered transitively through settings.test.ts, but the catch in preferredMotion — a browser that refuses the query string — is the one branch nothing exercises. loadSettings's own catch has a test, so the precedent is there; a throwing matchMedia stub closes it in three lines.
  2. Your equality test now pins the rest of the HUD identical on both settings, which deliberately keeps the hit marker's r = 9 + age * 20 growth and the kill feed's clamp(8 - age, 0, 1) fade. I think that is defensible — one is shot feedback, the other is an eight-second-old line leaving — but it is a decision the test freezes, so anyone who later wants the feed's fade turned down will meet you there.

The line between decoration and the game is drawn where the issue asked, the README says why in the sentence that matters, and the change is small enough to read in one sitting.

(Housekeeping: the bare "probe" comment above is mine, from finding this endpoint's shape — this forge's /reviews API refuses a review without a line anchor, so the verdict is anchored here. The API gives me no way to delete it; please ignore it.)

44 // A query string a browser refuses to parse is not a reason to fail to load.
45 return DEFAULT_MOTION;
46 }
47 }
src/client/renderer.test.ts +68
@@ -1197,3 +1197,71 @@ test("a round that ends takes its holes with it: the next one starts on clean wa
11971197 const again = wallHoles(record(renderer, calls, world)) as { vertices: number };
11981198 expect(again.vertices).toBe(DECAL_VERTICES);
11991199 });
1200
1201 // --- motion: the walk's sway is the only thing reduced motion takes off the
1202 // hands, and it is taken by a number the renderer keeps rather than a branch
1203 // the frame makes.
1204
1205 /** The gun's place this frame, from a renderer the test drives itself. */
1206 const heldAt = (renderer: Renderer, calls: Call[], world: World): Float32Array => {
1207 calls.length = 0;
1208 renderer.draw(world, CAMERA, world.me, 1 / 60);
1209 return heldPlace(calls, "rifle") as Float32Array;
1210 };
1211
1212 test("the gun sways with the walk, and holds still under reduced motion", () => {
1213 const { canvas, calls } = recorder();
1214 const renderer = createRenderer(canvas) as Renderer;
1215 const world = scene();
1216 world.me.weapon = "ak47";
1217 world.me.vel = [MOVE.maxSpeed, 0, 0];
1218 heldAt(renderer, calls, world); // the first frame starts the raise
1219
1220 // Full motion: two frames a sixtieth of a second apart are not the same gun.
1221 const first = heldAt(renderer, calls, world);
1222 const second = heldAt(renderer, calls, world);
1223 expect(second[12]).not.toBeCloseTo(first[12] as number, 6);
1224 expect(second[13]).not.toBeCloseTo(first[13] as number, 6);
1225
1226 // Reduced: the same two frames are the same matrix, to the last float.
1227 renderer.setMotion("reduced");
1228 const still = heldAt(renderer, calls, world);
1229 expect(heldAt(renderer, calls, world)).toEqual(still);
1230
1231 // And it is the bob that went and nothing else: walking with it off draws
1232 // exactly the gun standing still with it on already drew.
1233 renderer.setMotion("full");
1234 world.me.vel = [0, 0, 0];
1235 expect(heldAt(renderer, calls, world)).toEqual(still);
1236
1237 // The switch is live both ways — a player who turns it back on is walking
1238 // again on the next frame, with no reload and no new renderer.
1239 world.me.vel = [MOVE.maxSpeed, 0, 0];
1240 expect(heldAt(renderer, calls, world)).not.toEqual(still);
1241 });
1242
1243 test("reduced motion keeps the kick, the reload and the flash: they are things that happened", () => {
1244 const { canvas, calls } = recorder();
1245 const renderer = createRenderer(canvas) as Renderer;
1246 const world = scene();
1247 world.me.weapon = "ak47";
1248 world.me.vel = [0, 0, 0];
1249 renderer.setMotion("reduced");
1250 heldAt(renderer, calls, world);
1251 world.time += 1; // past the raise, so the gun is up and level
1252 const level = heldAt(renderer, calls, world);
1253
1254 // A shot a moment ago still kicks the gun back and up.
1255 world.me.lastShotAt = world.time;
1256 const kicked = heldAt(renderer, calls, world);
1257 expect(kicked[14]).toBeGreaterThan(level[14] as number);
1258 expect(kicked[13]).toBeLessThan(level[13] as number);
1259 // And the muzzle flash off the end of it is still drawn: it is what says a
1260 // gun fired, which is information and not decoration.
1261 expect(lineRuns(calls).length).toBe(1);
1262
1263 // A reload is still the hands doing something you can see.
1264 world.me.lastShotAt = -99;
1265 world.me.reloadUntil = world.time + 1.25;
1266 expect(heldAt(renderer, calls, world)[13]).toBeLessThan((level[13] as number) - 0.4);
1267 });
src/client/renderer.ts +25 −3
@@ -23,6 +23,7 @@ import {
2323 viewModelMesh, type Mesh,
2424 } from "./models.ts";
2525 import type { MapDef } from "./map.ts";
26 import { DEFAULT_MOTION, type Motion } from "./motion.ts";
2627 import { hinge, neutral, phaseAt, stride, type Stride } from "./stride.ts";
2728 import { MAX_SCALE, MIN_SCALE } from "./quality.ts";
2829 import { LAYERS, TEX_SIZE, materialTextures } from "./textures.ts";
@@ -369,6 +370,15 @@ export type Renderer = {
369370 * so nothing on the page reflows when it changes.
370371 */
371372 setScale(scale: number): void;
373 /**
374 * Does the decoration move? Taken as a value when the setting changes and
375 * kept as a number the frame multiplies by, so `draw` never asks the
376 * question and there is no second path through the hot loop. The only thing
377 * it turns off in here is the walk's bob on the gun in your hands: the
378 * kick, the raise, the reload and every muzzle flash are things that
379 * happened, and a player is meant to see and fight all four.
380 */
381 setMotion(motion: Motion): void;
372382 /**
373383 * Has the browser taken the context away? The loop reads this once a frame —
374384 * it is a flag set by an event, never a `gl.isContextLost()` in the frame
@@ -433,6 +443,14 @@ export function createRenderer(canvas: HTMLCanvasElement, hooks: RendererHooks =
433443 scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, next));
434444 };
435445
446 // How much of the walk's sway reaches the gun: 1 or 0, set when the player
447 // changes the setting. A frame multiplies by it and asks nothing.
448 let bobScale = 1;
449 const setMotion = (motion: Motion): void => {
450 bobScale = motion === "reduced" ? 0 : 1;
451 };
452 setMotion(DEFAULT_MOTION); // so the default is in one place and not two
453
436454 // The four places a blended pose is put while it is being drawn from, made
437455 // once here rather than per frame: a renderer that interpolated into a fresh
438456 // tuple would allocate one per player, per flash and per grenade, every
@@ -764,8 +782,12 @@ export function createRenderer(canvas: HTMLCanvasElement, hooks: RendererHooks =
764782 if (view.alive && !view.scoped) {
765783 const speed = Math.hypot(view.vel[0], view.vel[1]);
766784 bobPhase += dt * (4 + speed * 0.02);
767 const bobX = Math.sin(bobPhase) * Math.min(speed / 250, 1) * 0.05;
768 const bobY = Math.abs(Math.cos(bobPhase)) * Math.min(speed / 250, 1) * 0.04;
785 // The walk's sway, which says nothing a player can act on: under reduced
786 // motion `bobScale` is 0 and both terms are exactly zero, which is the
787 // same gun a player standing still is already holding.
788 const sway = Math.min(speed / 250, 1) * bobScale;
789 const bobX = Math.sin(bobPhase) * sway * 0.05;
790 const bobY = Math.abs(Math.cos(bobPhase)) * sway * 0.04;
769791 const kick = Math.max(0, 0.12 - (world.time - view.lastShotAt)) * 2.2;
770792 const weapon = WEAPONS[view.weapon];
771793 const kind = weapon?.kind ?? "rifle";
@@ -851,5 +873,5 @@ export function createRenderer(canvas: HTMLCanvasElement, hooks: RendererHooks =
851873 gl.bindVertexArray(null);
852874 };
853875
854 return { draw, resize, setScale, gl, get lost(): boolean { return lost; } };
876 return { draw, resize, setScale, setMotion, gl, get lost(): boolean { return lost; } };
855877 }
src/client/settings.test.ts +112 −4
@@ -8,8 +8,10 @@ import { expect, test } from "bun:test";
88 import { DEFAULT_CROSSHAIR } from "./crosshair.ts";
99 import { DEFAULT_BINDINGS } from "./input.ts";
1010 import { MAP_NAMES } from "./map.ts";
11 import { MOTIONS, REDUCED_MOTION_QUERY } from "./motion.ts";
1112 import {
12 DEFAULT_SETTINGS, parseCrosshair, parseSettings, type Settings, ZOOM_RATIO_MAX, ZOOM_RATIO_MIN,
13 DEFAULT_SETTINGS, loadSettings, parseCrosshair, parseSettings, type Settings,
14 ZOOM_RATIO_MAX, ZOOM_RATIO_MIN,
1315 } from "./settings.ts";
1416 import { DEFAULT_ZOOM_RATIO } from "./zoom.ts";
1517
@@ -31,11 +33,11 @@ test("a JSON value that isn't an object is the default", () => {
3133 test("out-of-range numbers are clamped, not rejected", () => {
3234 expect(parseSettings('{"volume":5,"sensitivity":9999,"zoomRatio":4}')).toEqual({
3335 muted: false, volume: 1, sensitivity: 12, zoomRatio: 1, difficulty: "normal", map: "de_forge",
34 crosshair: DEFAULT_CROSSHAIR, bindings: DEFAULT_BINDINGS,
36 motion: "full", crosshair: DEFAULT_CROSSHAIR, bindings: DEFAULT_BINDINGS,
3537 });
3638 expect(parseSettings('{"volume":-3,"sensitivity":-1,"zoomRatio":-1}')).toEqual({
3739 muted: false, volume: 0, sensitivity: 0.2, zoomRatio: 0.1, difficulty: "normal", map: "de_forge",
38 crosshair: DEFAULT_CROSSHAIR, bindings: DEFAULT_BINDINGS,
40 motion: "full", crosshair: DEFAULT_CROSSHAIR, bindings: DEFAULT_BINDINGS,
3941 });
4042 });
4143
@@ -55,7 +57,7 @@ test("a whole, valid settings object round-trips", () => {
5557 const crosshair = { colour: "cyan", size: 12, gap: 2, thickness: 3, dot: true, dynamic: false } as const;
5658 const stored: Settings = {
5759 muted: true, volume: 0.4, sensitivity: 3.5, zoomRatio: 0.5, difficulty: "hard", map: "de_forge",
58 crosshair, bindings,
60 motion: "reduced", crosshair, bindings,
5961 };
6062 expect(parseSettings(JSON.stringify(stored))).toEqual(stored);
6163 });
@@ -229,3 +231,109 @@ test("a corrupt blob can never leave an action unbound", () => {
229231 expect(codes.every((code) => typeof code === "string" && code.length > 0)).toBe(true);
230232 expect(new Set(codes).size).toBe(codes.length);
231233 });
234
235 // --- motion: validated the way difficulty is, and defaulted from the system
236 // only where nobody has chosen.
237
238 test("a missing motion is the default", () => {
239 expect(parseSettings('{"muted":true}').motion).toBe("full");
240 expect(parseSettings(null).motion).toBe("full");
241 });
242
243 test("a garbage motion is the default", () => {
244 expect(parseSettings('{"motion":123}').motion).toBe("full");
245 expect(parseSettings('{"motion":null}').motion).toBe("full");
246 expect(parseSettings('{"motion":["reduced"]}').motion).toBe("full");
247 expect(parseSettings('{"motion":{"reduced":true}}').motion).toBe("full");
248 expect(parseSettings('{"motion":true}').motion).toBe("full");
249 });
250
251 test("an unknown motion name is the default, prototype keys included", () => {
252 expect(parseSettings('{"motion":"none"}').motion).toBe("full");
253 expect(parseSettings('{"motion":"REDUCED"}').motion).toBe("full");
254 expect(parseSettings('{"motion":"reduce"}').motion).toBe("full");
255 expect(parseSettings('{"motion":"toString"}').motion).toBe("full");
256 expect(parseSettings('{"motion":"constructor"}').motion).toBe("full");
257 });
258
259 test("every motion the game offers survives being stored and read back", () => {
260 for (const name of MOTIONS) expect(parseSettings(JSON.stringify({ motion: name })).motion).toBe(name);
261 expect(MOTIONS.length).toBe(2);
262 });
263
264 test("a settings blob written before motion existed still loads, and nothing else moves", () => {
265 // Exactly what `saveSettings` wrote yesterday: no `motion` key at all.
266 const old = '{"muted":true,"volume":0.4,"sensitivity":3.5,"difficulty":"hard","map":"de_cinder",' +
267 `"crosshair":${JSON.stringify(DEFAULT_CROSSHAIR)},"bindings":${JSON.stringify(DEFAULT_BINDINGS)}}`;
268 const parsed = parseSettings(old);
269 expect(parsed.motion).toBe("full");
270 expect(parsed.sensitivity).toBe(3.5);
271 expect(parsed.map).toBe("de_cinder");
272 });
273
274 test("the system's answer is the motion of a player who has not chosen, and never of one who has", () => {
275 // Nothing stored, and a blob from before the setting existed: both are
276 // people who have not chosen, so the system's answer is theirs.
277 expect(parseSettings(null, "reduced").motion).toBe("reduced");
278 expect(parseSettings('{"muted":true}', "reduced").motion).toBe("reduced");
279 expect(parseSettings("not json", "reduced").motion).toBe("reduced");
280 // A choice that is in storage is read first and wins outright, both ways
281 // round: the query cannot turn motion down for somebody who asked for it,
282 // and cannot turn it back up for somebody who turned it off.
283 expect(parseSettings('{"motion":"full"}', "reduced").motion).toBe("full");
284 expect(parseSettings('{"motion":"reduced"}', "full").motion).toBe("reduced");
285 });
286
287 /**
288 * Bun has no `localStorage` and no `matchMedia`; both are stubbed around one
289 * call, the way saved.test.ts stubs storage, so `loadSettings` can be held to
290 * the one claim that needs a browser to make: it asks the media query, and it
291 * asks it only where storage has no answer.
292 */
293 function withBrowser<T>(stored: string | null, prefersReduced: boolean, run: () => T): { value: T; asked: string[] } {
294 const global = globalThis as { localStorage?: unknown; matchMedia?: unknown };
295 const before = { localStorage: global.localStorage, matchMedia: global.matchMedia };
296 const asked: string[] = [];
297 global.localStorage = {
298 getItem: (key: string) => (key === "cs.settings" ? stored : null),
299 setItem: () => {},
300 removeItem: () => {},
301 };
302 global.matchMedia = (query: string) => {
303 asked.push(query);
304 return { matches: prefersReduced && query === REDUCED_MOTION_QUERY };
305 };
306 try {
307 return { value: run(), asked };
308 } finally {
309 global.localStorage = before.localStorage;
310 global.matchMedia = before.matchMedia;
311 }
312 }
313
314 test("a first run on a machine that asks for reduced motion loads reduced, and asks the right query", () => {
315 const first = withBrowser(null, true, loadSettings);
316 expect(first.value.motion).toBe("reduced");
317 expect(first.asked).toEqual([REDUCED_MOTION_QUERY]);
318
319 // And a machine that asks for nothing is the game as it has always drawn.
320 expect(withBrowser(null, false, loadSettings).value.motion).toBe("full");
321 });
322
323 test("a stored choice survives whatever the machine asks for, in both directions", () => {
324 expect(withBrowser('{"motion":"full"}', true, loadSettings).value.motion).toBe("full");
325 expect(withBrowser('{"motion":"reduced"}', false, loadSettings).value.motion).toBe("reduced");
326 });
327
328 test("storage that throws — a private tab — still starts at what the system asks for", () => {
329 const global = globalThis as { localStorage?: unknown; matchMedia?: unknown };
330 const before = { localStorage: global.localStorage, matchMedia: global.matchMedia };
331 global.localStorage = { getItem: () => { throw new Error("denied"); } };
332 global.matchMedia = (query: string) => ({ matches: query === REDUCED_MOTION_QUERY });
333 try {
334 expect(loadSettings().motion).toBe("reduced");
335 } finally {
336 global.localStorage = before.localStorage;
337 global.matchMedia = before.matchMedia;
338 }
339 });
src/client/settings.ts +31 −12
@@ -1,7 +1,7 @@
11 /**
22 * Settings a player can reach mid-match: mute, volume, look sensitivity, the
3 * scope's share of it, bot difficulty, the crosshair and key bindings. Stored
4 * in localStorage beside `cs.name`.
3 * scope's share of it, bot difficulty, motion, the crosshair and key bindings.
4 * Stored in localStorage beside `cs.name`.
55 * `parseSettings` is the boundary — a missing, malformed, out-of-range or
66 * unknown value falls back to the default and never puts NaN, an
77 * unrecognised string or an unbound action into the game. What a difficulty
@@ -16,6 +16,7 @@ import {
1616 } from "./crosshair.ts";
1717 import { ACTIONS, DEFAULT_BINDINGS, type Action } from "./input.ts";
1818 import { DEFAULT_MAP, MAP_NAMES, type MapName } from "./map.ts";
19 import { DEFAULT_MOTION, MOTIONS, preferredMotion, type Motion } from "./motion.ts";
1920 import { DEFAULT_ZOOM_RATIO, ZOOM_RATIO_MAX, ZOOM_RATIO_MIN } from "./zoom.ts";
2021
2122 export type Settings = {
@@ -26,13 +27,15 @@ export type Settings = {
2627 zoomRatio: number;
2728 difficulty: Difficulty;
2829 map: MapName;
30 /** Whether the decoration moves. What that covers is motion.ts's. */
31 motion: Motion;
2932 crosshair: CrosshairStyle;
3033 bindings: Record<Action, string>;
3134 };
3235
3336 export const DEFAULT_SETTINGS: Settings = {
3437 muted: false, volume: 1, sensitivity: 2.2, zoomRatio: DEFAULT_ZOOM_RATIO,
35 difficulty: DEFAULT_DIFFICULTY, map: DEFAULT_MAP,
38 difficulty: DEFAULT_DIFFICULTY, map: DEFAULT_MAP, motion: DEFAULT_MOTION,
3639 crosshair: DEFAULT_CROSSHAIR,
3740 bindings: DEFAULT_BINDINGS,
3841 };
@@ -48,6 +51,9 @@ const isDifficulty = (value: unknown): value is Difficulty =>
4851 const isMapName = (value: unknown): value is MapName =>
4952 typeof value === "string" && (MAP_NAMES as readonly string[]).includes(value);
5053
54 const isMotion = (value: unknown): value is Motion =>
55 typeof value === "string" && (MOTIONS as readonly string[]).includes(value);
56
5157 /**
5258 * Every action ends up with a code, and no two actions ever end up with the
5359 * same one. An action's stored code is kept if it is a non-empty string and
@@ -115,16 +121,25 @@ export function parseCrosshair(raw: unknown): CrosshairStyle {
115121 };
116122 }
117123
118 /** Pure: whatever was in storage, in or out of shape, becomes safe settings. */
119 export function parseSettings(raw: string | null): Settings {
120 if (raw === null) return defaultSettings();
124 /**
125 * Pure: whatever was in storage, in or out of shape, becomes safe settings.
126 *
127 * `unchosen` is the motion of a player who has not chosen one — the system's
128 * own `prefers-reduced-motion`, which `loadSettings` reads. It is a default
129 * and cannot be anything else: a stored `motion` this function recognises is
130 * taken before `unchosen` is ever looked at, so the media query moves nobody
131 * who has touched the setting. Left out, it is the motion this game has
132 * always drawn, which is what a caller with no browser to ask means.
133 */
134 export function parseSettings(raw: string | null, unchosen: Motion = DEFAULT_MOTION): Settings {
135 if (raw === null) return defaultSettings(unchosen);
121136 let parsed: unknown;
122137 try {
123138 parsed = JSON.parse(raw);
124139 } catch {
125 return defaultSettings();
140 return defaultSettings(unchosen);
126141 }
127 if (typeof parsed !== "object" || parsed === null) return defaultSettings();
142 if (typeof parsed !== "object" || parsed === null) return defaultSettings(unchosen);
128143 const obj = parsed as Record<string, unknown>;
129144 return {
130145 muted: obj.muted === true,
@@ -133,19 +148,23 @@ export function parseSettings(raw: string | null): Settings {
133148 zoomRatio: bounded(obj.zoomRatio, ZOOM_RATIO_MIN, ZOOM_RATIO_MAX, DEFAULT_SETTINGS.zoomRatio),
134149 difficulty: isDifficulty(obj.difficulty) ? obj.difficulty : DEFAULT_SETTINGS.difficulty,
135150 map: isMapName(obj.map) ? obj.map : DEFAULT_SETTINGS.map,
151 motion: isMotion(obj.motion) ? obj.motion : unchosen,
136152 crosshair: parseCrosshair(obj.crosshair),
137153 bindings: parseBindings(obj.bindings),
138154 };
139155 }
140156
141 const defaultSettings = (): Settings =>
142 ({ ...DEFAULT_SETTINGS, crosshair: { ...DEFAULT_CROSSHAIR }, bindings: { ...DEFAULT_BINDINGS } });
157 const defaultSettings = (unchosen: Motion = DEFAULT_MOTION): Settings =>
158 ({ ...DEFAULT_SETTINGS, motion: unchosen, crosshair: { ...DEFAULT_CROSSHAIR }, bindings: { ...DEFAULT_BINDINGS } });
143159
144160 export function loadSettings(): Settings {
161 // Asked once, at load, and only ever as the fallback for a motion nothing
162 // has stored: the browser gets to suggest, and the player gets to decide.
163 const unchosen = preferredMotion();
145164 try {
146 return parseSettings(localStorage.getItem(KEY));
165 return parseSettings(localStorage.getItem(KEY), unchosen);
147166 } catch {
148 return defaultSettings();
167 return defaultSettings(unchosen);
149168 }
150169 }
151170
src/pages/game.test.ts +18
@@ -7,6 +7,7 @@ import { credit } from "../player.ts";
77 import type { Result } from "../result.ts";
88 import { CROSSHAIR_COLOUR_NAMES, CROSSHAIR_LIMITS } from "../client/crosshair.ts";
99 import { DEFAULT_MAP, MAP_NAMES } from "../client/map.ts";
10 import { DEFAULT_MOTION, MOTIONS } from "../client/motion.ts";
1011 import { DEFAULT_ZOOM_RATIO, HIP_FOV, TIGHTEST_FOV, ZOOM_RATIO_MAX, ZOOM_RATIO_MIN } from "../client/zoom.ts";
1112 import { gamePage, statsHtml } from "./game.ts";
1213
@@ -185,3 +186,20 @@ test("a crosshair field can only offer the numbers the parser would keep", () =>
185186 expect(panel).not.toContain(`id="menu-xhair-colour" type="text"`);
186187 for (const name of CROSSHAIR_COLOUR_NAMES) expect(panel).toContain(`<option value="${name}">`);
187188 });
189
190 test("the motion switch sits in the menu and in the pause overlay, offering exactly what the parser keeps", () => {
191 const html = gamePage(db, options);
192 for (const [overlay, id] of [["menu", "motion"], ["pause", "pause-motion"]] as const) {
193 const panel = element(html, overlay);
194 expect(panel).toContain(`<select id="${id}">`);
195 // Every value the parser accepts and nothing else: a control that offered
196 // a third word would show a player a setting the game never had.
197 for (const name of MOTIONS) expect(panel).toContain(`<option value="${name}"`);
198 const offered = new RegExp(`<select id="${id}">(.*?)</select>`, "s").exec(panel)?.[1] ?? "";
199 expect([...offered.matchAll(/value="([^"]+)"/g)].map((m) => m[1])).toEqual([...MOTIONS]);
200 expect(panel).toContain(`<option value="${DEFAULT_MOTION}" selected>`);
201 }
202 // The page says what Reduced changes and what it deliberately does not.
203 expect(html).toContain("prefers-reduced-motion");
204 expect(html).toContain("A view that does not kick would be an aim advantage");
205 });
src/pages/game.ts +31 −1
@@ -8,6 +8,7 @@ import type { Database } from "bun:sqlite";
88 import { CROSSHAIR_COLOUR_NAMES, CROSSHAIR_LIMITS } from "../client/crosshair.ts";
99 import { DEFAULT_ZOOM_RATIO, HIP_FOV, TIGHTEST_FOV, ZOOM_RATIO_MAX, ZOOM_RATIO_MIN } from "../client/zoom.ts";
1010 import { DEFAULT_MAP, MAP_NAMES } from "../client/map.ts";
11 import { DEFAULT_MOTION, MOTION_LABELS, MOTIONS } from "../client/motion.ts";
1112 import { headshotShare, ratio, recentMatches, standings, type MatchRow, type Standing } from "../db.ts";
1213 import { isGuest, type Player } from "../player.ts";
1314 import { FORGE_LINK_ATTRS, signInHref } from "../who.ts";
@@ -124,6 +125,23 @@ export function zoomControl(id: "zoom-sens" | "pause-zoom-sens"): string {
124125 `<small>× while scoped</small></label>`;
125126 }
126127
128 /**
129 * The motion switch, rendered once in the menu and once in the pause overlay
130 * and kept in step by `main.ts` like every other pair. Both options come out
131 * of `MOTIONS`, the same list `parseSettings` validates against, so the
132 * control can never offer a value the parser would throw away. It is rendered
133 * at the default and moved by `main.ts` on the first frame — to whatever was
134 * stored, or on a first run to what the system asks for — which costs no
135 * layout: a `<select>` is as wide as its widest option either way.
136 */
137 export function motionControl(id: "motion" | "pause-motion"): string {
138 const options = MOTIONS
139 .map((name) =>
140 `<option value="${name}"${name === DEFAULT_MOTION ? " selected" : ""}>${MOTION_LABELS[name]}</option>`)
141 .join("");
142 return `<label>Motion<select id="${id}">${options}</select></label>`;
143 }
144
127145 const STYLE = `
128146 :root { color-scheme: dark; --bg:#0b0d11; --panel:#141821; --line:#232a36; --text:#e7eaf0; --dim:#9aa4b4; --t:#e2b04a; --ct:#6fa8e8; }
129147 * { box-sizing: border-box; }
@@ -303,6 +321,7 @@ export function gamePage(db: Database, options: PageOptions): string {
303321 .join("")}</select></label>
304322 <label>Sensitivity<input id="sens" type="number" min="0.2" max="12" step="0.1" value="2.2"></label>
305323 ${zoomControl("zoom-sens")}
324 ${motionControl("motion")}
306325 </div>
307326 <div class="row"><button type="button" class="primary" id="resume-match" hidden>Resume match</button></div>
308327 <p class="hint" id="discard-warning" hidden>Starting a new match discards the saved round above.</p>
@@ -318,6 +337,13 @@ export function gamePage(db: Database, options: PageOptions): string {
318337 ${DEFAULT_ZOOM_RATIO} is the default and it is not a taste: the scope draws ${TIGHTEST_FOV}° where the hip
319338 draws ${HIP_FOV}°, and that number is what makes a flick cross the same share of the screen either way.
320339 Raise it for a quicker scope; <code>1</code> is the sensitivity untouched.</p>
340 <p class="hint"><b>Motion</b> starts at whatever your system asks for — <code>prefers-reduced-motion</code>
341 — and is yours to change here or in the pause overlay. <b>Reduced</b> turns down the motion that is
342 only decoration: the gun stops swaying as you walk, and a damage wedge holds still for its second
343 instead of sliding away. Nothing you play against moves: the view still kicks with the recoil, you
344 still flinch when you are hit, the crosshair still opens with your spread, and the tracers, the
345 flashes and the blood are the same. A view that does not kick would be an aim advantage, not an
346 accessibility setting.</p>
321347 <p class="hint"><kbd>Z</kbd> opens the radio: <b>go A</b>, <b>go B</b> or <b>hold</b>. Your four
322348 teammates hear it and turn round — a call stands for twenty seconds and then the side goes
323349 back to the plan it rolled. The other side never hears you.</p>
@@ -345,10 +371,14 @@ export function gamePage(db: Database, options: PageOptions): string {
345371 <option value="normal">Normal</option>
346372 <option value="hard">Hard</option>
347373 </select></label>
374 ${motionControl("pause-motion")}
348375 <button type="button" id="mute" aria-pressed="false">Mute</button>
349376 <button type="button" id="fullscreen-pause" aria-pressed="false" hidden>Fullscreen</button>
350377 </div>
351 <p class="hint">Difficulty takes effect from the next round. Playing on <b id="pause-map"></b> — chosen in the menu, fixed for the match.</p>
378 <p class="hint">Difficulty takes effect from the next round. Playing on <b id="pause-map"></b> — chosen in the menu, fixed for the match.
379 <b>Motion: Reduced</b> takes hold on the next frame and turns down only decoration — the walk's
380 sway on the gun and the damage wedge's fade. The recoil, the flinch and the crosshair are the
381 game and never move with it.</p>
352382 <details class="controls"><summary>Controls</summary><div id="controls-pause"></div></details>
353383 ${crosshairControls("pause")}
354384 <div class="row"><button type="button" class="primary" id="resume">Resume</button><button type="button" id="quit">Leave match</button></div>
src/readme.test.ts +20
@@ -9,6 +9,7 @@ import { expect, test } from "bun:test";
99 import { readFileSync } from "node:fs";
1010 import { join } from "node:path";
1111 import { DEFAULT_MAP, MAP_NAMES } from "./client/map.ts";
12 import { MOTION_LABELS, MOTIONS } from "./client/motion.ts";
1213 import { DEFAULT_ZOOM_RATIO, HIP_FOV, TIGHTEST_FOV } from "./client/zoom.ts";
1314
1415 const root = join(import.meta.dir, "..");
@@ -119,3 +120,22 @@ test("the readme says which map the policy was trained on and what that costs",
119120 const round = section("Measuring the round");
120121 for (const name of MAP_NAMES) expect(round).toContain(name);
121122 });
123
124 // The one setting where saying what it does *not* do is the whole of the
125 // promise: a reduced-motion switch that touched the recoil would be an aim
126 // advantage, so the README has to name the line and the code has to hold it.
127 test("the readme says where the motion switch is, what it changes and what it may not", () => {
128 const playing = section("Playing");
129 expect(playing).toContain("**Motion**");
130 expect(playing).toContain("in the menu and again in the pause overlay");
131 expect(playing).toContain("(prefers-reduced-motion: reduce)");
132 expect(playing).toContain("a choice you have made is read first and wins outright");
133 // The two things it turns down, named.
134 expect(playing).toContain("walk's sway");
135 expect(playing).toContain("fade on a damage\nwedge");
136 // And the line it may not cross, in the sentence that says why.
137 expect(playing).toContain("A view that\ndoes not kick is an aim advantage, not an accessibility feature");
138 // Both settings are named, and under the words the control shows.
139 for (const name of MOTIONS) expect(playing).toContain(`**${MOTION_LABELS[name]}**`);
140 expect(MOTIONS.length).toBe(2);
141 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs