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
Check · Review · Land · Live done — Landed and live in production.
vesper/cs:vesper/77-motion-you-did-not-ask-for-honour-prefer → main 1 commit fork 1 thread · 1 unresolved
README.md +40 −2
| @@ -305,6 +305,41 @@ running — a difficulty picked there takes effect from the next round, never | ||
| 305 | 305 | the one in progress, so changing it mid-fight cannot hand a bot new hands |
| 306 | 306 | between one shot and the next. |
| 307 | 307 | |
| 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 | ||
| 308 | 343 | **The crosshair is yours.** A collapsed **Crosshair** disclosure sits under |
| 309 | 344 | Controls in the menu and again in the pause overlay, with the same six |
| 310 | 345 | 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. | ||
| 540 | 575 | footstep, a dry trigger, a plant and a defuse carry, the bomb's beep and its |
| 541 | 576 | blast carrying everywhere, and the cue it hands `main` — position, name, |
| 542 | 577 | 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), | |
| 545 | 583 | `renderer`, `hud`, `input`, `audio`, `main`. |
| 546 | 584 | Everything except the last four and `weights` is headless and tested; |
| 547 | 585 | `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"; | ||
| 9 | 9 | import { DEFAULT_CROSSHAIR, type CrosshairStyle } from "./crosshair.ts"; |
| 10 | 10 | import { drawHud } from "./hud.ts"; |
| 11 | 11 | import { SHORT } from "./match.ts"; |
| 12 | import type { Motion } from "./motion.ts"; | |
| 12 | 13 | import type { Camera } from "./renderer.ts"; |
| 13 | 14 | import { createWorld, eyePos, type Player, type World } from "./world.ts"; |
| 14 | 15 | |
| @@ -579,3 +580,81 @@ test("a name plate sits over the body the renderer drew, not the tick it left", | ||
| 579 | 580 | expect(oldest).not.toBeCloseTo(newest, 0); |
| 580 | 581 | expect(half).toBeCloseTo((oldest + newest) / 2, 0); |
| 581 | 582 | }); |
| 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 | ||
| 12 | 12 | import { lerpPos } from "./interpolate.ts"; |
| 13 | 13 | import { angleDelta, clamp, rad, type Vec3 } from "./math.ts"; |
| 14 | 14 | import { walkable, type MapDef } from "./map.ts"; |
| 15 | import { DEFAULT_MOTION, type Motion } from "./motion.ts"; | |
| 15 | 16 | import { hullHeight, MOVE } from "./physics.ts"; |
| 16 | 17 | import { project, type Projected } from "./project.ts"; |
| 17 | 18 | import { buildRadar } from "./radar.ts"; |
| @@ -72,6 +73,15 @@ function hurtVignette(ctx: CanvasRenderingContext2D, width: number, height: numb | ||
| 72 | 73 | * hits stack: each is its own wedge, fading on its own clock. A spectator sees |
| 73 | 74 | * neither: `hurt` effects are only ever pushed for the person, so a teammate's |
| 74 | 75 | * 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. | |
| 75 | 85 | */ |
| 76 | 86 | function drawDamage( |
| 77 | 87 | ctx: CanvasRenderingContext2D, |
| @@ -79,6 +89,7 @@ function drawDamage( | ||
| 79 | 89 | view: Player, |
| 80 | 90 | width: number, |
| 81 | 91 | height: number, |
| 92 | steady: boolean, | |
| 82 | 93 | ): void { |
| 83 | 94 | const low = clamp((55 - view.health) / 55, 0, 1); |
| 84 | 95 | if (view.alive && low > 0) { |
| @@ -100,7 +111,8 @@ function drawDamage( | ||
| 100 | 111 | // that angle, a quarter turn back from straight up. |
| 101 | 112 | const middle = rad(-angleDelta(view.yaw, bearing)) - Math.PI / 2; |
| 102 | 113 | 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); | |
| 104 | 116 | ctx.strokeStyle = "#ff4238"; |
| 105 | 117 | ctx.lineWidth = Math.max(3, Math.round(radius * 0.13)); |
| 106 | 118 | ctx.beginPath(); |
| @@ -314,16 +326,21 @@ export function drawHud( | ||
| 314 | 326 | showScoreboard: boolean, |
| 315 | 327 | crosshair: CrosshairStyle = DEFAULT_CROSSHAIR, |
| 316 | 328 | alpha = 1, |
| 329 | motion: Motion = DEFAULT_MOTION, | |
| 317 | 330 | ): void { |
| 318 | 331 | ctx.clearRect(0, 0, width, height); |
| 319 | 332 | 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"; | |
| 320 | 337 | const spectating = view !== world.me; |
| 321 | 338 | const over = world.match.phase === "match_over"; |
| 322 | 339 | const weapon = currentWeapon(view); |
| 323 | 340 | const pad = Math.max(12, Math.round(width * 0.02)); |
| 324 | 341 | |
| 325 | 342 | // --- 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); | |
| 327 | 344 | |
| 328 | 345 | // --- crosshair, opening up exactly as much as the gun is inaccurate — as |
| 329 | 346 | // 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"; | ||
| 20 | 20 | import { clamp } from "./math.ts"; |
| 21 | 21 | import { COMPETITIVE, SHORT, type Rules } from "./match.ts"; |
| 22 | 22 | import type { MapName } from "./map.ts"; |
| 23 | import { MOTIONS, type Motion } from "./motion.ts"; | |
| 23 | 24 | import { MAX_SCALE, nextRenderScale } from "./quality.ts"; |
| 24 | 25 | import { createRenderer, type Camera } from "./renderer.ts"; |
| 25 | 26 | import type { CrosshairStyle } from "./crosshair.ts"; |
| @@ -65,6 +66,8 @@ function boot(): void { | ||
| 65 | 66 | const difficultyField = $<HTMLSelectElement>("difficulty"); |
| 66 | 67 | const pauseDifficultyField = $<HTMLSelectElement>("pause-difficulty"); |
| 67 | 68 | const mapField = $<HTMLSelectElement>("map"); |
| 69 | const motionField = $<HTMLSelectElement>("motion"); | |
| 70 | const pauseMotionField = $<HTMLSelectElement>("pause-motion"); | |
| 68 | 71 | const pauseMapLabel = $("pause-map"); |
| 69 | 72 | const resumeMatchButton = $<HTMLButtonElement>("resume-match"); |
| 70 | 73 | const discardWarning = $("discard-warning"); |
| @@ -158,6 +161,20 @@ function boot(): void { | ||
| 158 | 161 | mapField.value = next; |
| 159 | 162 | saveSettings(settings); |
| 160 | 163 | }; |
| 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 | }; | |
| 161 | 178 | const updateMuteButton = (): void => { |
| 162 | 179 | muteButton.textContent = settings.muted ? "Unmute" : "Mute"; |
| 163 | 180 | muteButton.setAttribute("aria-pressed", String(settings.muted)); |
| @@ -170,6 +187,11 @@ function boot(): void { | ||
| 170 | 187 | difficultyField.value = settings.difficulty; |
| 171 | 188 | pauseDifficultyField.value = settings.difficulty; |
| 172 | 189 | 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); | |
| 173 | 195 | updateMuteButton(); |
| 174 | 196 | |
| 175 | 197 | // "change" rather than "input": a number field mid-edit (cleared, or "0.") |
| @@ -181,6 +203,8 @@ function boot(): void { | ||
| 181 | 203 | difficultyField.addEventListener("change", () => syncDifficulty(difficultyField.value)); |
| 182 | 204 | pauseDifficultyField.addEventListener("change", () => syncDifficulty(pauseDifficultyField.value)); |
| 183 | 205 | mapField.addEventListener("change", () => syncMap(mapField.value)); |
| 206 | motionField.addEventListener("change", () => syncMotion(motionField.value)); | |
| 207 | pauseMotionField.addEventListener("change", () => syncMotion(pauseMotionField.value)); | |
| 184 | 208 | volumeField.addEventListener("input", () => { |
| 185 | 209 | const next = Math.min(1, Math.max(0, Number(volumeField.value) / 100)); |
| 186 | 210 | settings = { ...settings, volume: Number.isFinite(next) ? next : settings.volume }; |
| @@ -196,7 +220,8 @@ function boot(): void { | ||
| 196 | 220 | // The pause dialog sits over a live match: a key typed into one of its |
| 197 | 221 | // fields must never reach the window-level handler that drives the game. |
| 198 | 222 | for (const field of [ |
| 199 | pauseSensField, pauseZoomField, volumeField, muteButton, pauseDifficultyField, fullscreenPauseButton, | |
| 223 | pauseSensField, pauseZoomField, volumeField, muteButton, pauseDifficultyField, pauseMotionField, | |
| 224 | fullscreenPauseButton, | |
| 200 | 225 | ]) |
| 201 | 226 | field.addEventListener("keydown", (event) => event.stopPropagation()); |
| 202 | 227 | |
| @@ -911,7 +936,7 @@ function boot(): void { | ||
| 911 | 936 | } |
| 912 | 937 | // The same camera the scene was drawn with: the HUD's name plates have to |
| 913 | 938 | // 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); | |
| 915 | 940 | // The fps is the frame time and nothing else; the scale is an aside, shown |
| 916 | 941 | // only when it is costing you pixels, so a slow machine says why. |
| 917 | 942 | 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 { | |
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
Two small things for later, neither worth holding this up
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 |
||
| 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 | ||
| 1197 | 1197 | const again = wallHoles(record(renderer, calls, world)) as { vertices: number }; |
| 1198 | 1198 | expect(again.vertices).toBe(DECAL_VERTICES); |
| 1199 | 1199 | }); |
| 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 { | ||
| 23 | 23 | viewModelMesh, type Mesh, |
| 24 | 24 | } from "./models.ts"; |
| 25 | 25 | import type { MapDef } from "./map.ts"; |
| 26 | import { DEFAULT_MOTION, type Motion } from "./motion.ts"; | |
| 26 | 27 | import { hinge, neutral, phaseAt, stride, type Stride } from "./stride.ts"; |
| 27 | 28 | import { MAX_SCALE, MIN_SCALE } from "./quality.ts"; |
| 28 | 29 | import { LAYERS, TEX_SIZE, materialTextures } from "./textures.ts"; |
| @@ -369,6 +370,15 @@ export type Renderer = { | ||
| 369 | 370 | * so nothing on the page reflows when it changes. |
| 370 | 371 | */ |
| 371 | 372 | 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; | |
| 372 | 382 | /** |
| 373 | 383 | * Has the browser taken the context away? The loop reads this once a frame — |
| 374 | 384 | * 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 = | ||
| 433 | 443 | scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, next)); |
| 434 | 444 | }; |
| 435 | 445 | |
| 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 | ||
| 436 | 454 | // The four places a blended pose is put while it is being drawn from, made |
| 437 | 455 | // once here rather than per frame: a renderer that interpolated into a fresh |
| 438 | 456 | // tuple would allocate one per player, per flash and per grenade, every |
| @@ -764,8 +782,12 @@ export function createRenderer(canvas: HTMLCanvasElement, hooks: RendererHooks = | ||
| 764 | 782 | if (view.alive && !view.scoped) { |
| 765 | 783 | const speed = Math.hypot(view.vel[0], view.vel[1]); |
| 766 | 784 | 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; | |
| 769 | 791 | const kick = Math.max(0, 0.12 - (world.time - view.lastShotAt)) * 2.2; |
| 770 | 792 | const weapon = WEAPONS[view.weapon]; |
| 771 | 793 | const kind = weapon?.kind ?? "rifle"; |
| @@ -851,5 +873,5 @@ export function createRenderer(canvas: HTMLCanvasElement, hooks: RendererHooks = | ||
| 851 | 873 | gl.bindVertexArray(null); |
| 852 | 874 | }; |
| 853 | 875 | |
| 854 | return { draw, resize, setScale, gl, get lost(): boolean { return lost; } }; | |
| 876 | return { draw, resize, setScale, setMotion, gl, get lost(): boolean { return lost; } }; | |
| 855 | 877 | } |
src/client/settings.test.ts +112 −4
| @@ -8,8 +8,10 @@ import { expect, test } from "bun:test"; | ||
| 8 | 8 | import { DEFAULT_CROSSHAIR } from "./crosshair.ts"; |
| 9 | 9 | import { DEFAULT_BINDINGS } from "./input.ts"; |
| 10 | 10 | import { MAP_NAMES } from "./map.ts"; |
| 11 | import { MOTIONS, REDUCED_MOTION_QUERY } from "./motion.ts"; | |
| 11 | 12 | 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, | |
| 13 | 15 | } from "./settings.ts"; |
| 14 | 16 | import { DEFAULT_ZOOM_RATIO } from "./zoom.ts"; |
| 15 | 17 | |
| @@ -31,11 +33,11 @@ test("a JSON value that isn't an object is the default", () => { | ||
| 31 | 33 | test("out-of-range numbers are clamped, not rejected", () => { |
| 32 | 34 | expect(parseSettings('{"volume":5,"sensitivity":9999,"zoomRatio":4}')).toEqual({ |
| 33 | 35 | 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, | |
| 35 | 37 | }); |
| 36 | 38 | expect(parseSettings('{"volume":-3,"sensitivity":-1,"zoomRatio":-1}')).toEqual({ |
| 37 | 39 | 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, | |
| 39 | 41 | }); |
| 40 | 42 | }); |
| 41 | 43 | |
| @@ -55,7 +57,7 @@ test("a whole, valid settings object round-trips", () => { | ||
| 55 | 57 | const crosshair = { colour: "cyan", size: 12, gap: 2, thickness: 3, dot: true, dynamic: false } as const; |
| 56 | 58 | const stored: Settings = { |
| 57 | 59 | muted: true, volume: 0.4, sensitivity: 3.5, zoomRatio: 0.5, difficulty: "hard", map: "de_forge", |
| 58 | crosshair, bindings, | |
| 60 | motion: "reduced", crosshair, bindings, | |
| 59 | 61 | }; |
| 60 | 62 | expect(parseSettings(JSON.stringify(stored))).toEqual(stored); |
| 61 | 63 | }); |
| @@ -229,3 +231,109 @@ test("a corrupt blob can never leave an action unbound", () => { | ||
| 229 | 231 | expect(codes.every((code) => typeof code === "string" && code.length > 0)).toBe(true); |
| 230 | 232 | expect(new Set(codes).size).toBe(codes.length); |
| 231 | 233 | }); |
| 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 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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`. | |
| 5 | 5 | * `parseSettings` is the boundary — a missing, malformed, out-of-range or |
| 6 | 6 | * unknown value falls back to the default and never puts NaN, an |
| 7 | 7 | * unrecognised string or an unbound action into the game. What a difficulty |
| @@ -16,6 +16,7 @@ import { | ||
| 16 | 16 | } from "./crosshair.ts"; |
| 17 | 17 | import { ACTIONS, DEFAULT_BINDINGS, type Action } from "./input.ts"; |
| 18 | 18 | import { DEFAULT_MAP, MAP_NAMES, type MapName } from "./map.ts"; |
| 19 | import { DEFAULT_MOTION, MOTIONS, preferredMotion, type Motion } from "./motion.ts"; | |
| 19 | 20 | import { DEFAULT_ZOOM_RATIO, ZOOM_RATIO_MAX, ZOOM_RATIO_MIN } from "./zoom.ts"; |
| 20 | 21 | |
| 21 | 22 | export type Settings = { |
| @@ -26,13 +27,15 @@ export type Settings = { | ||
| 26 | 27 | zoomRatio: number; |
| 27 | 28 | difficulty: Difficulty; |
| 28 | 29 | map: MapName; |
| 30 | /** Whether the decoration moves. What that covers is motion.ts's. */ | |
| 31 | motion: Motion; | |
| 29 | 32 | crosshair: CrosshairStyle; |
| 30 | 33 | bindings: Record<Action, string>; |
| 31 | 34 | }; |
| 32 | 35 | |
| 33 | 36 | export const DEFAULT_SETTINGS: Settings = { |
| 34 | 37 | 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, | |
| 36 | 39 | crosshair: DEFAULT_CROSSHAIR, |
| 37 | 40 | bindings: DEFAULT_BINDINGS, |
| 38 | 41 | }; |
| @@ -48,6 +51,9 @@ const isDifficulty = (value: unknown): value is Difficulty => | ||
| 48 | 51 | const isMapName = (value: unknown): value is MapName => |
| 49 | 52 | typeof value === "string" && (MAP_NAMES as readonly string[]).includes(value); |
| 50 | 53 | |
| 54 | const isMotion = (value: unknown): value is Motion => | |
| 55 | typeof value === "string" && (MOTIONS as readonly string[]).includes(value); | |
| 56 | ||
| 51 | 57 | /** |
| 52 | 58 | * Every action ends up with a code, and no two actions ever end up with the |
| 53 | 59 | * 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 { | ||
| 115 | 121 | }; |
| 116 | 122 | } |
| 117 | 123 | |
| 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); | |
| 121 | 136 | let parsed: unknown; |
| 122 | 137 | try { |
| 123 | 138 | parsed = JSON.parse(raw); |
| 124 | 139 | } catch { |
| 125 | return defaultSettings(); | |
| 140 | return defaultSettings(unchosen); | |
| 126 | 141 | } |
| 127 | if (typeof parsed !== "object" || parsed === null) return defaultSettings(); | |
| 142 | if (typeof parsed !== "object" || parsed === null) return defaultSettings(unchosen); | |
| 128 | 143 | const obj = parsed as Record<string, unknown>; |
| 129 | 144 | return { |
| 130 | 145 | muted: obj.muted === true, |
| @@ -133,19 +148,23 @@ export function parseSettings(raw: string | null): Settings { | ||
| 133 | 148 | zoomRatio: bounded(obj.zoomRatio, ZOOM_RATIO_MIN, ZOOM_RATIO_MAX, DEFAULT_SETTINGS.zoomRatio), |
| 134 | 149 | difficulty: isDifficulty(obj.difficulty) ? obj.difficulty : DEFAULT_SETTINGS.difficulty, |
| 135 | 150 | map: isMapName(obj.map) ? obj.map : DEFAULT_SETTINGS.map, |
| 151 | motion: isMotion(obj.motion) ? obj.motion : unchosen, | |
| 136 | 152 | crosshair: parseCrosshair(obj.crosshair), |
| 137 | 153 | bindings: parseBindings(obj.bindings), |
| 138 | 154 | }; |
| 139 | 155 | } |
| 140 | 156 | |
| 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 } }); | |
| 143 | 159 | |
| 144 | 160 | 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(); | |
| 145 | 164 | try { |
| 146 | return parseSettings(localStorage.getItem(KEY)); | |
| 165 | return parseSettings(localStorage.getItem(KEY), unchosen); | |
| 147 | 166 | } catch { |
| 148 | return defaultSettings(); | |
| 167 | return defaultSettings(unchosen); | |
| 149 | 168 | } |
| 150 | 169 | } |
| 151 | 170 |
src/pages/game.test.ts +18
| @@ -7,6 +7,7 @@ import { credit } from "../player.ts"; | ||
| 7 | 7 | import type { Result } from "../result.ts"; |
| 8 | 8 | import { CROSSHAIR_COLOUR_NAMES, CROSSHAIR_LIMITS } from "../client/crosshair.ts"; |
| 9 | 9 | import { DEFAULT_MAP, MAP_NAMES } from "../client/map.ts"; |
| 10 | import { DEFAULT_MOTION, MOTIONS } from "../client/motion.ts"; | |
| 10 | 11 | import { DEFAULT_ZOOM_RATIO, HIP_FOV, TIGHTEST_FOV, ZOOM_RATIO_MAX, ZOOM_RATIO_MIN } from "../client/zoom.ts"; |
| 11 | 12 | import { gamePage, statsHtml } from "./game.ts"; |
| 12 | 13 | |
| @@ -185,3 +186,20 @@ test("a crosshair field can only offer the numbers the parser would keep", () => | ||
| 185 | 186 | expect(panel).not.toContain(`id="menu-xhair-colour" type="text"`); |
| 186 | 187 | for (const name of CROSSHAIR_COLOUR_NAMES) expect(panel).toContain(`<option value="${name}">`); |
| 187 | 188 | }); |
| 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"; | ||
| 8 | 8 | import { CROSSHAIR_COLOUR_NAMES, CROSSHAIR_LIMITS } from "../client/crosshair.ts"; |
| 9 | 9 | import { DEFAULT_ZOOM_RATIO, HIP_FOV, TIGHTEST_FOV, ZOOM_RATIO_MAX, ZOOM_RATIO_MIN } from "../client/zoom.ts"; |
| 10 | 10 | import { DEFAULT_MAP, MAP_NAMES } from "../client/map.ts"; |
| 11 | import { DEFAULT_MOTION, MOTION_LABELS, MOTIONS } from "../client/motion.ts"; | |
| 11 | 12 | import { headshotShare, ratio, recentMatches, standings, type MatchRow, type Standing } from "../db.ts"; |
| 12 | 13 | import { isGuest, type Player } from "../player.ts"; |
| 13 | 14 | import { FORGE_LINK_ATTRS, signInHref } from "../who.ts"; |
| @@ -124,6 +125,23 @@ export function zoomControl(id: "zoom-sens" | "pause-zoom-sens"): string { | ||
| 124 | 125 | `<small>× while scoped</small></label>`; |
| 125 | 126 | } |
| 126 | 127 | |
| 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 | ||
| 127 | 145 | const STYLE = ` |
| 128 | 146 | :root { color-scheme: dark; --bg:#0b0d11; --panel:#141821; --line:#232a36; --text:#e7eaf0; --dim:#9aa4b4; --t:#e2b04a; --ct:#6fa8e8; } |
| 129 | 147 | * { box-sizing: border-box; } |
| @@ -303,6 +321,7 @@ export function gamePage(db: Database, options: PageOptions): string { | ||
| 303 | 321 | .join("")}</select></label> |
| 304 | 322 | <label>Sensitivity<input id="sens" type="number" min="0.2" max="12" step="0.1" value="2.2"></label> |
| 305 | 323 | ${zoomControl("zoom-sens")} |
| 324 | ${motionControl("motion")} | |
| 306 | 325 | </div> |
| 307 | 326 | <div class="row"><button type="button" class="primary" id="resume-match" hidden>Resume match</button></div> |
| 308 | 327 | <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 { | ||
| 318 | 337 | ${DEFAULT_ZOOM_RATIO} is the default and it is not a taste: the scope draws ${TIGHTEST_FOV}° where the hip |
| 319 | 338 | draws ${HIP_FOV}°, and that number is what makes a flick cross the same share of the screen either way. |
| 320 | 339 | 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> | |
| 321 | 347 | <p class="hint"><kbd>Z</kbd> opens the radio: <b>go A</b>, <b>go B</b> or <b>hold</b>. Your four |
| 322 | 348 | teammates hear it and turn round — a call stands for twenty seconds and then the side goes |
| 323 | 349 | 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 { | ||
| 345 | 371 | <option value="normal">Normal</option> |
| 346 | 372 | <option value="hard">Hard</option> |
| 347 | 373 | </select></label> |
| 374 | ${motionControl("pause-motion")} | |
| 348 | 375 | <button type="button" id="mute" aria-pressed="false">Mute</button> |
| 349 | 376 | <button type="button" id="fullscreen-pause" aria-pressed="false" hidden>Fullscreen</button> |
| 350 | 377 | </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> | |
| 352 | 382 | <details class="controls"><summary>Controls</summary><div id="controls-pause"></div></details> |
| 353 | 383 | ${crosshairControls("pause")} |
| 354 | 384 | <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"; | ||
| 9 | 9 | import { readFileSync } from "node:fs"; |
| 10 | 10 | import { join } from "node:path"; |
| 11 | 11 | import { DEFAULT_MAP, MAP_NAMES } from "./client/map.ts"; |
| 12 | import { MOTION_LABELS, MOTIONS } from "./client/motion.ts"; | |
| 12 | 13 | import { DEFAULT_ZOOM_RATIO, HIP_FOV, TIGHTEST_FOV } from "./client/zoom.ts"; |
| 13 | 14 | |
| 14 | 15 | 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", | ||
| 119 | 120 | const round = section("Measuring the round"); |
| 120 | 121 | for (const name of MAP_NAMES) expect(round).toContain(name); |
| 121 | 122 | }); |
| 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 | }); |
probe