Split The code is public; the app answers only to the crew.
The AWP's scope zooms the view but not the mouse: 40° of view turns as fast as 90°, so a scoped flick overshoots #198
Check · Review · Land · Live done — Landed and live in production.
vesper/cs:vesper/182-the-awp-s-scope-zooms-the-view-but-not-t → main 1 commit fork
11 files
changed +351
−23
README.md +19 −2
| @@ -185,8 +185,25 @@ time, <kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> switch guns and <kbd>4</kbd> takes | ||
| 185 | 185 | out the HE grenade if you bought one (fire throws it, and the hand goes back to |
| 186 | 186 | the gun once it is gone), <kbd>Z</kbd> opens the radio, <kbd>Tab</kbd> shows |
| 187 | 187 | the scoreboard, <kbd>Esc</kbd> pauses the match on a desktop and `menu` on the |
| 188 | pad does it on a phone. Pausing also opens mute, volume and | |
| 189 | look sensitivity, which take effect at once and are remembered for next time. | |
| 188 | pad does it on a phone. Pausing also opens mute, volume, | |
| 189 | look sensitivity and the scope's own share of it, which take effect at once and | |
| 190 | are remembered for next time. | |
| 191 | ||
| 192 | **The scope has its own sensitivity.** The AWP draws 40° where the hip draws | |
| 193 | 90°, so one number for both means the same flick sweeps the same degrees across | |
| 194 | less than half as much screen — the scoped turn that overshoots. **Scope sens** | |
| 195 | is what the look is multiplied by while the scope is up, mouse and thumb alike, | |
| 196 | and it sits beside **Sensitivity** in the menu and again in the pause overlay, | |
| 197 | the two in step and live mid-match. Its default `0.36` is not a taste: it is | |
| 198 | `tan 20° / tan 45°`, the correction that makes a flick cross the same share of | |
| 199 | the screen scoped and unscoped. The whole relationship lives in | |
| 200 | `src/client/zoom.ts` — the hip's `HIP_FOV` there and the AWP's `zoom` in | |
| 201 | `src/client/weapons.ts` are what the default is derived from, and the camera | |
| 202 | takes its field of view from the same function, so there is one place to change | |
| 203 | and no second place to forget. Raise it for a quicker scope, down to `0.1` for a | |
| 204 | slower one, or set it to `1` for the old behaviour where the scope turns exactly | |
| 205 | as fast as the hip. It is the person's view only: no bot's turn rate moves with | |
| 206 | it, and nothing the policy sees changes. | |
| 190 | 207 | |
| 191 | 208 | Every one of those keys is a default, not a floor: **WASD is not everyone's |
| 192 | 209 | WASD** — on an AZERTY board, a Dvorak layout or just a different habit, the |
src/client/input.test.ts +56 −1
| @@ -6,8 +6,12 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { afterEach, expect, test } from "bun:test"; |
| 9 | import { attachButtons, createControls, DEFAULT_BINDINGS, type Controls } from "./input.ts"; | |
| 9 | import { | |
| 10 | attachButtons, createControls, DEFAULT_BINDINGS, lookDelta, MOUSE_DEGREES, TOUCH_DEGREES, | |
| 11 | type Controls, | |
| 12 | } from "./input.ts"; | |
| 10 | 13 | import { downAt } from "./press.ts"; |
| 14 | import { DEFAULT_ZOOM_RATIO } from "./zoom.ts"; | |
| 11 | 15 | |
| 12 | 16 | type Listener = (event: unknown) => void; |
| 13 | 17 | |
| @@ -128,6 +132,57 @@ test("the left third is still the stick, and a second finger there does not fire | ||
| 128 | 132 | expect(controls.inputAt(controls.take(1), 0).side).toBe(0); |
| 129 | 133 | }); |
| 130 | 134 | |
| 135 | // --- the scope's share of the look: the same movement is worth fewer degrees | |
| 136 | // while the view is zoomed, and the pure function is where that is decided. | |
| 137 | ||
| 138 | test("a mouse delta is scaled by the zoom while scoped, and untouched while not", () => { | |
| 139 | const plain = lookDelta(100, 2.2, false, DEFAULT_ZOOM_RATIO); | |
| 140 | expect(plain).toBeCloseTo(100 * 2.2 * MOUSE_DEGREES, 10); | |
| 141 | expect(lookDelta(100, 2.2, true, DEFAULT_ZOOM_RATIO)).toBeCloseTo(plain * DEFAULT_ZOOM_RATIO, 10); | |
| 142 | // Less than half as fast at the default: the whole point of the setting. | |
| 143 | expect(lookDelta(100, 2.2, true, DEFAULT_ZOOM_RATIO)).toBeLessThan(plain / 2); | |
| 144 | // A ratio of 1 is the old behaviour, scoped or not. | |
| 145 | expect(lookDelta(100, 2.2, true, 1)).toBeCloseTo(plain, 10); | |
| 146 | // Direction survives the scaling. | |
| 147 | expect(lookDelta(-100, 2.2, true, DEFAULT_ZOOM_RATIO)).toBeCloseTo(-plain * DEFAULT_ZOOM_RATIO, 10); | |
| 148 | // A thumb's pixels are worth more degrees than a mouse's, scaled the same way. | |
| 149 | const thumb = lookDelta(100, 2.2, false, DEFAULT_ZOOM_RATIO, TOUCH_DEGREES); | |
| 150 | expect(thumb).toBeCloseTo(100 * 2.2 * TOUCH_DEGREES, 10); | |
| 151 | expect(lookDelta(100, 2.2, true, DEFAULT_ZOOM_RATIO, TOUCH_DEGREES)) | |
| 152 | .toBeCloseTo(thumb * DEFAULT_ZOOM_RATIO, 10); | |
| 153 | }); | |
| 154 | ||
| 155 | test("a locked mouse turns less while the scope is up, and the ratio takes effect mid-scope", () => { | |
| 156 | const { canvas: _canvas, controls } = harness(); | |
| 157 | const window = globals.window as Canvas; | |
| 158 | controls.locked = true; | |
| 159 | ||
| 160 | window.fire("mousemove", { movementX: 50, movementY: 0 }); | |
| 161 | const [hip] = controls.takeLook(); | |
| 162 | expect(hip).toBeCloseTo(50 * 2.2 * MOUSE_DEGREES, 10); | |
| 163 | ||
| 164 | controls.scoped = true; | |
| 165 | window.fire("mousemove", { movementX: 50, movementY: 0 }); | |
| 166 | expect(controls.takeLook()[0]).toBeCloseTo(hip * DEFAULT_ZOOM_RATIO, 10); | |
| 167 | ||
| 168 | // What the pause overlay's field does: the next movement feels it at once. | |
| 169 | controls.zoomRatio = 1; | |
| 170 | window.fire("mousemove", { movementX: 50, movementY: 0 }); | |
| 171 | expect(controls.takeLook()[0]).toBeCloseTo(hip, 10); | |
| 172 | }); | |
| 173 | ||
| 174 | test("a drag while scoped is scaled too — the pad is not left with the old behaviour", () => { | |
| 175 | const { canvas, controls } = harness(); | |
| 176 | touch(canvas, "touchstart", at(1, 700)); | |
| 177 | touch(canvas, "touchmove", at(1, 740)); | |
| 178 | const [hip] = controls.takeLook(); | |
| 179 | expect(hip).toBeCloseTo(40 * 2.2 * TOUCH_DEGREES, 10); | |
| 180 | ||
| 181 | controls.scoped = true; | |
| 182 | touch(canvas, "touchmove", at(1, 780)); | |
| 183 | expect(controls.takeLook()[0]).toBeCloseTo(hip * DEFAULT_ZOOM_RATIO, 10); | |
| 184 | }); | |
| 185 | ||
| 131 | 186 | // --- the buttons on the pad, which are the same action names as the keys |
| 132 | 187 | |
| 133 | 188 | /** A fake `[data-action]` button, and a root that hands it to attachButtons. */ |
src/client/input.ts +44 −6
| @@ -8,13 +8,36 @@ | ||
| 8 | 8 | * fires, exactly once. |
| 9 | 9 | * |
| 10 | 10 | * `lookX`/`lookY` accumulate in exactly two places — a locked mouse move and a |
| 11 | * touch whose role is `look` — and are drained once a frame by `takeLook`. | |
| 12 | * Buttons never touch them; they only ever name an action. | |
| 11 | * touch whose role is `look` — and both go through `lookDelta`, which is where | |
| 12 | * a pointer's movement becomes degrees of turn and the only place the scope's | |
| 13 | * zoom touches the mouse. Buttons never touch them; they only ever name an | |
| 14 | * action. | |
| 13 | 15 | */ |
| 14 | 16 | |
| 15 | 17 | import { clamp } from "./math.ts"; |
| 16 | 18 | import { createPress, downAt, type Frame } from "./press.ts"; |
| 17 | 19 | import { NO_INPUT, type Input } from "./world.ts"; |
| 20 | import { DEFAULT_ZOOM_RATIO, lookScale } from "./zoom.ts"; | |
| 21 | ||
| 22 | /** Degrees of turn per pixel of locked mouse movement, at sensitivity 1. */ | |
| 23 | export const MOUSE_DEGREES = 0.022; | |
| 24 | /** The same for a dragging finger, which travels far less than a mouse does. */ | |
| 25 | export const TOUCH_DEGREES = 0.06; | |
| 26 | ||
| 27 | /** | |
| 28 | * Pure: the degrees of turn one pointer movement is worth. `perPixel` says | |
| 29 | * which pointer moved, and `scoped` with `zoomRatio` is the scope's share of | |
| 30 | * it — zoom.ts owns that relationship, this only applies it. | |
| 31 | */ | |
| 32 | export function lookDelta( | |
| 33 | movement: number, | |
| 34 | sensitivity: number, | |
| 35 | scoped: boolean, | |
| 36 | zoomRatio: number, | |
| 37 | perPixel: number = MOUSE_DEGREES, | |
| 38 | ): number { | |
| 39 | return movement * sensitivity * perPixel * lookScale(scoped, zoomRatio); | |
| 40 | } | |
| 18 | 41 | |
| 19 | 42 | /** The controls a press of counts even if the release beat the next frame. */ |
| 20 | 43 | export const LATCHED = ["fire", "jump", "use", "reload"]; |
| @@ -29,6 +52,14 @@ export type Controls = { | ||
| 29 | 52 | /** 1 primary, 2 pistol, 3 knife, 4 grenade — main knows what they are. */ |
| 30 | 53 | readonly slotWanted: 1 | 2 | 3 | 4 | null; |
| 31 | 54 | sensitivity: number; |
| 55 | /** The scope's share of that sensitivity while it is up — see zoom.ts. */ | |
| 56 | zoomRatio: number; | |
| 57 | /** | |
| 58 | * Whether the person is looking down a scope right now. The loop sets it from | |
| 59 | * the world once a frame; the events in between scale their look by it, so the | |
| 60 | * worst a scope going up or down costs is the one frame it happened on. | |
| 61 | */ | |
| 62 | scoped: boolean; | |
| 32 | 63 | locked: boolean; |
| 33 | 64 | setAction(action: string, down: boolean): void; |
| 34 | 65 | /** A rebind takes hold on the very next key event — no detach, no reload. */ |
| @@ -87,6 +118,7 @@ function reverseBindings(bindings: Record<Action, string>): Record<string, Actio | ||
| 87 | 118 | |
| 88 | 119 | export function createControls( |
| 89 | 120 | canvas: HTMLCanvasElement, sensitivity = 2.2, bindings: Record<Action, string> = DEFAULT_BINDINGS, |
| 121 | zoomRatio = DEFAULT_ZOOM_RATIO, | |
| 90 | 122 | ): Controls { |
| 91 | 123 | const press = createPress(LATCHED); |
| 92 | 124 | const input: Input = { ...NO_INPUT }; |
| @@ -124,6 +156,8 @@ export function createControls( | ||
| 124 | 156 | : press.isDown("slot2") ? 2 : press.isDown("slot1") ? 1 : null; |
| 125 | 157 | }, |
| 126 | 158 | sensitivity, |
| 159 | zoomRatio, | |
| 160 | scoped: false, | |
| 127 | 161 | locked: false, |
| 128 | 162 | setAction: (action, down) => { |
| 129 | 163 | if (down) press.down(action); |
| @@ -197,8 +231,8 @@ export function createControls( | ||
| 197 | 231 | canvas.addEventListener("contextmenu", (event) => event.preventDefault()); |
| 198 | 232 | window.addEventListener("mousemove", (event) => { |
| 199 | 233 | if (!controls.locked) return; |
| 200 | lookX += event.movementX * controls.sensitivity * 0.022; | |
| 201 | lookY += event.movementY * controls.sensitivity * 0.022; | |
| 234 | lookX += lookDelta(event.movementX, controls.sensitivity, controls.scoped, controls.zoomRatio); | |
| 235 | lookY += lookDelta(event.movementY, controls.sensitivity, controls.scoped, controls.zoomRatio); | |
| 202 | 236 | }); |
| 203 | 237 | window.addEventListener("blur", () => { |
| 204 | 238 | controls.clear(); |
| @@ -237,8 +271,12 @@ export function createControls( | ||
| 237 | 271 | stick.x = clamp((touch.clientX - state.startX) / 60, -1, 1); |
| 238 | 272 | stick.y = clamp((state.startY - touch.clientY) / 60, -1, 1); |
| 239 | 273 | } else if (state.role === "look") { |
| 240 | lookX += (touch.clientX - state.x) * controls.sensitivity * 0.06; | |
| 241 | lookY += (touch.clientY - state.y) * controls.sensitivity * 0.06; | |
| 274 | // The same scaling a mouse gets: a drag under a scope is the same | |
| 275 | // problem, and a thumb has less screen to give away than a mouse does. | |
| 276 | const look = (movement: number): number => | |
| 277 | lookDelta(movement, controls.sensitivity, controls.scoped, controls.zoomRatio, TOUCH_DEGREES); | |
| 278 | lookX += look(touch.clientX - state.x); | |
| 279 | lookY += look(touch.clientY - state.y); | |
| 242 | 280 | } |
| 243 | 281 | state.x = touch.clientX; |
| 244 | 282 | state.y = touch.clientY; |
src/client/main.ts +31 −3
| @@ -25,7 +25,9 @@ import { createRenderer, type Camera } from "./renderer.ts"; | ||
| 25 | 25 | import type { CrosshairStyle } from "./crosshair.ts"; |
| 26 | 26 | import { |
| 27 | 27 | DEFAULT_SETTINGS, loadSettings, parseCrosshair, saveSettings, SENSITIVITY_MAX, SENSITIVITY_MIN, |
| 28 | ZOOM_RATIO_MAX, ZOOM_RATIO_MIN, | |
| 28 | 29 | } from "./settings.ts"; |
| 30 | import { fovFor } from "./zoom.ts"; | |
| 29 | 31 | import { CALL_LIFE, isCall, type Call } from "./plan.ts"; |
| 30 | 32 | import { clearSaved, readSaved, resumeLine, snapshot, writeSaved } from "./saved.ts"; |
| 31 | 33 | import { nextWatched, watched } from "./spectate.ts"; |
| @@ -68,6 +70,8 @@ function boot(): void { | ||
| 68 | 70 | const discardWarning = $("discard-warning"); |
| 69 | 71 | const sensField = $<HTMLInputElement>("sens"); |
| 70 | 72 | const pauseSensField = $<HTMLInputElement>("pause-sens"); |
| 73 | const zoomField = $<HTMLInputElement>("zoom-sens"); | |
| 74 | const pauseZoomField = $<HTMLInputElement>("pause-zoom-sens"); | |
| 71 | 75 | const volumeField = $<HTMLInputElement>("volume"); |
| 72 | 76 | const muteButton = $<HTMLButtonElement>("mute"); |
| 73 | 77 | const fullscreenMenuButton = $<HTMLButtonElement>("fullscreen-menu"); |
| @@ -103,7 +107,7 @@ function boot(): void { | ||
| 103 | 107 | // click is already in force once the context exists to hear it. |
| 104 | 108 | audio.setVolume(settings.volume); |
| 105 | 109 | audio.setMuted(settings.muted); |
| 106 | const controls = createControls(scene, settings.sensitivity, settings.bindings); | |
| 110 | const controls = createControls(scene, settings.sensitivity, settings.bindings, settings.zoomRatio); | |
| 107 | 111 | attachButtons(document, controls); |
| 108 | 112 | |
| 109 | 113 | // The menu's sensitivity field and the pause overlay's are the same number: |
| @@ -118,6 +122,19 @@ function boot(): void { | ||
| 118 | 122 | pauseSensField.value = String(next); |
| 119 | 123 | saveSettings(settings); |
| 120 | 124 | }; |
| 125 | // The scope's share of that sensitivity, paired the same way. It reaches the | |
| 126 | // mouse and the finger through the same `controls`, so a change lands on the | |
| 127 | // very next movement — mid-match, mid-scope. | |
| 128 | const syncZoomRatio = (value: number): void => { | |
| 129 | const next = Number.isFinite(value) | |
| 130 | ? Math.min(ZOOM_RATIO_MAX, Math.max(ZOOM_RATIO_MIN, value)) | |
| 131 | : DEFAULT_SETTINGS.zoomRatio; | |
| 132 | settings = { ...settings, zoomRatio: next }; | |
| 133 | controls.zoomRatio = next; | |
| 134 | zoomField.value = String(next); | |
| 135 | pauseZoomField.value = String(next); | |
| 136 | saveSettings(settings); | |
| 137 | }; | |
| 121 | 138 | // Same pairing as sensitivity: the menu's field picks it before a match, the |
| 122 | 139 | // pause overlay's changes it mid-match — but `setDifficulty` only queues the |
| 123 | 140 | // change for the next round, so a bot already in a fight keeps its hands. |
| @@ -147,6 +164,8 @@ function boot(): void { | ||
| 147 | 164 | }; |
| 148 | 165 | sensField.value = String(settings.sensitivity); |
| 149 | 166 | pauseSensField.value = String(settings.sensitivity); |
| 167 | zoomField.value = String(settings.zoomRatio); | |
| 168 | pauseZoomField.value = String(settings.zoomRatio); | |
| 150 | 169 | volumeField.value = String(Math.round(settings.volume * 100)); |
| 151 | 170 | difficultyField.value = settings.difficulty; |
| 152 | 171 | pauseDifficultyField.value = settings.difficulty; |
| @@ -157,6 +176,8 @@ function boot(): void { | ||
| 157 | 176 | // is not a value to clamp and snap back on every keystroke. |
| 158 | 177 | sensField.addEventListener("change", () => syncSensitivity(Number(sensField.value))); |
| 159 | 178 | pauseSensField.addEventListener("change", () => syncSensitivity(Number(pauseSensField.value))); |
| 179 | zoomField.addEventListener("change", () => syncZoomRatio(Number(zoomField.value))); | |
| 180 | pauseZoomField.addEventListener("change", () => syncZoomRatio(Number(pauseZoomField.value))); | |
| 160 | 181 | difficultyField.addEventListener("change", () => syncDifficulty(difficultyField.value)); |
| 161 | 182 | pauseDifficultyField.addEventListener("change", () => syncDifficulty(pauseDifficultyField.value)); |
| 162 | 183 | mapField.addEventListener("change", () => syncMap(mapField.value)); |
| @@ -174,7 +195,9 @@ function boot(): void { | ||
| 174 | 195 | }); |
| 175 | 196 | // The pause dialog sits over a live match: a key typed into one of its |
| 176 | 197 | // fields must never reach the window-level handler that drives the game. |
| 177 | for (const field of [pauseSensField, volumeField, muteButton, pauseDifficultyField, fullscreenPauseButton]) | |
| 198 | for (const field of [ | |
| 199 | pauseSensField, pauseZoomField, volumeField, muteButton, pauseDifficultyField, fullscreenPauseButton, | |
| 200 | ]) | |
| 178 | 201 | field.addEventListener("keydown", (event) => event.stopPropagation()); |
| 179 | 202 | |
| 180 | 203 | // --- the crosshair: the same six controls in the menu and in the pause |
| @@ -637,6 +660,8 @@ function boot(): void { | ||
| 637 | 660 | savedRound = 0; |
| 638 | 661 | watching = world.me.id; |
| 639 | 662 | controls.sensitivity = settings.sensitivity; |
| 663 | controls.zoomRatio = settings.zoomRatio; | |
| 664 | controls.scoped = false; | |
| 640 | 665 | running = true; |
| 641 | 666 | lastSound = 0; |
| 642 | 667 | show(menu, false); |
| @@ -811,6 +836,9 @@ function boot(): void { | ||
| 811 | 836 | update(world, TICK, input); |
| 812 | 837 | } |
| 813 | 838 | watching = nextWatched(world, watching, cycle); |
| 839 | // The scope the mouse now answers to: the person's own, never the teammate | |
| 840 | // a corpse is watching — a dead player's look moves nothing anyway. | |
| 841 | controls.scoped = world.me.scoped; | |
| 814 | 842 | feelEffects(world, watched(world, watching)); |
| 815 | 843 | |
| 816 | 844 | // The start of every freeze time, fresh round or resumed match alike: |
| @@ -850,7 +878,7 @@ function boot(): void { | ||
| 850 | 878 | pos: eyePos(view), |
| 851 | 879 | yaw: view.yaw - view.punch[0] * 0.4, |
| 852 | 880 | pitch: view.pitch - view.punch[1] * 0.4, |
| 853 | fov: view.scoped ? 40 : 90, | |
| 881 | fov: fovFor(view.weapon, view.scoped), | |
| 854 | 882 | }; |
| 855 | 883 | // Pixels are the cheapest thing to give up, so give them up on a timer and |
| 856 | 884 | // from the smoothed average — never from the one frame that just ran long. |
src/client/settings.test.ts +30 −6
| @@ -8,7 +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 { DEFAULT_SETTINGS, parseCrosshair, parseSettings, type Settings } from "./settings.ts"; | |
| 11 | import { | |
| 12 | DEFAULT_SETTINGS, parseCrosshair, parseSettings, type Settings, ZOOM_RATIO_MAX, ZOOM_RATIO_MIN, | |
| 13 | } from "./settings.ts"; | |
| 14 | import { DEFAULT_ZOOM_RATIO } from "./zoom.ts"; | |
| 12 | 15 | |
| 13 | 16 | test("nothing stored yet is the default", () => { |
| 14 | 17 | expect(parseSettings(null)).toEqual(DEFAULT_SETTINGS); |
| @@ -26,12 +29,12 @@ test("a JSON value that isn't an object is the default", () => { | ||
| 26 | 29 | }); |
| 27 | 30 | |
| 28 | 31 | test("out-of-range numbers are clamped, not rejected", () => { |
| 29 | expect(parseSettings('{"volume":5,"sensitivity":9999}')).toEqual({ | |
| 30 | muted: false, volume: 1, sensitivity: 12, difficulty: "normal", map: "de_forge", | |
| 32 | expect(parseSettings('{"volume":5,"sensitivity":9999,"zoomRatio":4}')).toEqual({ | |
| 33 | muted: false, volume: 1, sensitivity: 12, zoomRatio: 1, difficulty: "normal", map: "de_forge", | |
| 31 | 34 | crosshair: DEFAULT_CROSSHAIR, bindings: DEFAULT_BINDINGS, |
| 32 | 35 | }); |
| 33 | expect(parseSettings('{"volume":-3,"sensitivity":-1}')).toEqual({ | |
| 34 | muted: false, volume: 0, sensitivity: 0.2, difficulty: "normal", map: "de_forge", | |
| 36 | expect(parseSettings('{"volume":-3,"sensitivity":-1,"zoomRatio":-1}')).toEqual({ | |
| 37 | muted: false, volume: 0, sensitivity: 0.2, zoomRatio: 0.1, difficulty: "normal", map: "de_forge", | |
| 35 | 38 | crosshair: DEFAULT_CROSSHAIR, bindings: DEFAULT_BINDINGS, |
| 36 | 39 | }); |
| 37 | 40 | }); |
| @@ -51,11 +54,32 @@ test("a whole, valid settings object round-trips", () => { | ||
| 51 | 54 | const bindings = { ...DEFAULT_BINDINGS, forward: "KeyG" }; |
| 52 | 55 | const crosshair = { colour: "cyan", size: 12, gap: 2, thickness: 3, dot: true, dynamic: false } as const; |
| 53 | 56 | const stored: Settings = { |
| 54 | muted: true, volume: 0.4, sensitivity: 3.5, difficulty: "hard", map: "de_forge", crosshair, bindings, | |
| 57 | muted: true, volume: 0.4, sensitivity: 3.5, zoomRatio: 0.5, difficulty: "hard", map: "de_forge", | |
| 58 | crosshair, bindings, | |
| 55 | 59 | }; |
| 56 | 60 | expect(parseSettings(JSON.stringify(stored))).toEqual(stored); |
| 57 | 61 | }); |
| 58 | 62 | |
| 63 | test("the zoom ratio round-trips, and a value outside its limits never reaches the mouse", () => { | |
| 64 | expect(parseSettings('{"zoomRatio":0.42}').zoomRatio).toBe(0.42); | |
| 65 | // 1 is the old behaviour and has to survive being asked for exactly. | |
| 66 | expect(parseSettings('{"zoomRatio":1}').zoomRatio).toBe(1); | |
| 67 | expect(parseSettings('{"zoomRatio":9}').zoomRatio).toBe(ZOOM_RATIO_MAX); | |
| 68 | expect(parseSettings('{"zoomRatio":0}').zoomRatio).toBe(ZOOM_RATIO_MIN); | |
| 69 | expect(parseSettings('{"zoomRatio":-2}').zoomRatio).toBe(ZOOM_RATIO_MIN); | |
| 70 | for (const raw of ['{"zoomRatio":"0.5"}', '{"zoomRatio":null}', '{"zoomRatio":"NaN"}', '{"zoomRatio":[0.5]}']) | |
| 71 | expect(parseSettings(raw).zoomRatio).toBe(DEFAULT_ZOOM_RATIO); | |
| 72 | expect(parseSettings('{"muted":true}').zoomRatio).toBe(DEFAULT_ZOOM_RATIO); | |
| 73 | }); | |
| 74 | ||
| 75 | test("a settings blob written before the scope had its own sensitivity still loads", () => { | |
| 76 | const old = '{"muted":true,"volume":0.4,"sensitivity":3.5,"difficulty":"hard",' + | |
| 77 | `"crosshair":${JSON.stringify(DEFAULT_CROSSHAIR)},"bindings":${JSON.stringify(DEFAULT_BINDINGS)}}`; | |
| 78 | const parsed = parseSettings(old); | |
| 79 | expect(parsed.zoomRatio).toBe(DEFAULT_ZOOM_RATIO); | |
| 80 | expect(parsed.sensitivity).toBe(3.5); | |
| 81 | }); | |
| 82 | ||
| 59 | 83 | test("a settings blob written before the crosshair existed still loads, with today's crosshair", () => { |
| 60 | 84 | // Exactly what `saveSettings` wrote yesterday: no `crosshair` key at all. |
| 61 | 85 | const old = '{"muted":true,"volume":0.4,"sensitivity":3.5,"difficulty":"hard",' + |
src/client/settings.ts +12 −4
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | * Settings a player can reach mid-match: mute, volume, look sensitivity, bot | |
| 3 | * difficulty, the crosshair and key bindings. Stored in localStorage beside | |
| 4 | * `cs.name`. | |
| 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`. | |
| 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,11 +16,14 @@ 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_ZOOM_RATIO, ZOOM_RATIO_MAX, ZOOM_RATIO_MIN } from "./zoom.ts"; | |
| 19 | 20 | |
| 20 | 21 | export type Settings = { |
| 21 | 22 | muted: boolean; |
| 22 | 23 | volume: number; |
| 23 | 24 | sensitivity: number; |
| 25 | /** What the look sensitivity is multiplied by while a scope is up. */ | |
| 26 | zoomRatio: number; | |
| 24 | 27 | difficulty: Difficulty; |
| 25 | 28 | map: MapName; |
| 26 | 29 | crosshair: CrosshairStyle; |
| @@ -28,12 +31,16 @@ export type Settings = { | ||
| 28 | 31 | }; |
| 29 | 32 | |
| 30 | 33 | export const DEFAULT_SETTINGS: Settings = { |
| 31 | muted: false, volume: 1, sensitivity: 2.2, difficulty: DEFAULT_DIFFICULTY, map: DEFAULT_MAP, | |
| 34 | muted: false, volume: 1, sensitivity: 2.2, zoomRatio: DEFAULT_ZOOM_RATIO, | |
| 35 | difficulty: DEFAULT_DIFFICULTY, map: DEFAULT_MAP, | |
| 32 | 36 | crosshair: DEFAULT_CROSSHAIR, |
| 33 | 37 | bindings: DEFAULT_BINDINGS, |
| 34 | 38 | }; |
| 35 | 39 | export const SENSITIVITY_MIN = 0.2; |
| 36 | 40 | export const SENSITIVITY_MAX = 12; |
| 41 | // Re-exported so the one place that owns the zoom is the one place its limits | |
| 42 | // come from: this file only knows that the field is a number inside them. | |
| 43 | export { ZOOM_RATIO_MAX, ZOOM_RATIO_MIN }; | |
| 37 | 44 | |
| 38 | 45 | const isDifficulty = (value: unknown): value is Difficulty => |
| 39 | 46 | typeof value === "string" && (DIFFICULTIES as readonly string[]).includes(value); |
| @@ -123,6 +130,7 @@ export function parseSettings(raw: string | null): Settings { | ||
| 123 | 130 | muted: obj.muted === true, |
| 124 | 131 | volume: bounded(obj.volume, 0, 1, DEFAULT_SETTINGS.volume), |
| 125 | 132 | sensitivity: bounded(obj.sensitivity, SENSITIVITY_MIN, SENSITIVITY_MAX, DEFAULT_SETTINGS.sensitivity), |
| 133 | zoomRatio: bounded(obj.zoomRatio, ZOOM_RATIO_MIN, ZOOM_RATIO_MAX, DEFAULT_SETTINGS.zoomRatio), | |
| 126 | 134 | difficulty: isDifficulty(obj.difficulty) ? obj.difficulty : DEFAULT_SETTINGS.difficulty, |
| 127 | 135 | map: isMapName(obj.map) ? obj.map : DEFAULT_SETTINGS.map, |
| 128 | 136 | crosshair: parseCrosshair(obj.crosshair), |
src/client/zoom.test.ts added +45
| @@ -0,0 +1,45 @@ | ||
| 1 | /** | |
| 2 | * The zoom is one derivation: the fields of view the camera draws and the | |
| 3 | * sensitivity that belongs to them. What is worth checking is that the default | |
| 4 | * ratio is that derivation and not a number somebody typed, and that the | |
| 5 | * scale only ever touches a scoped look. | |
| 6 | */ | |
| 7 | ||
| 8 | import { expect, test } from "bun:test"; | |
| 9 | import { WEAPONS } from "./weapons.ts"; | |
| 10 | import { | |
| 11 | DEFAULT_ZOOM_RATIO, fovFor, HIP_FOV, lookScale, screenShare, TIGHTEST_FOV, | |
| 12 | ZOOM_RATIO_MAX, ZOOM_RATIO_MIN, | |
| 13 | } from "./zoom.ts"; | |
| 14 | ||
| 15 | test("a scoped camera draws the gun's own zoom, and everything else the hip's", () => { | |
| 16 | expect(fovFor("awp", true)).toBe(WEAPONS.awp.zoom as number); | |
| 17 | expect(fovFor("awp", false)).toBe(HIP_FOV); | |
| 18 | // No other gun has a scope, so asking for one gets the hip regardless. | |
| 19 | expect(fovFor("ak47", true)).toBe(HIP_FOV); | |
| 20 | expect(fovFor("knife", true)).toBe(HIP_FOV); | |
| 21 | }); | |
| 22 | ||
| 23 | test("the screen share is the tangent ratio of the two fields of view", () => { | |
| 24 | expect(screenShare(HIP_FOV)).toBeCloseTo(1, 10); | |
| 25 | // tan 20° / tan 45°, which is the AWP's 40 against the hip's 90. | |
| 26 | expect(screenShare(40)).toBeCloseTo(Math.tan(Math.PI / 9), 10); | |
| 27 | expect(screenShare(40)).toBeCloseTo(0.36397, 5); | |
| 28 | // Tighter scope, smaller share: monotone, or the correction is backwards. | |
| 29 | expect(screenShare(20)).toBeLessThan(screenShare(40)); | |
| 30 | }); | |
| 31 | ||
| 32 | test("the default ratio is derived from the tightest scope in the catalogue", () => { | |
| 33 | expect(TIGHTEST_FOV).toBe(WEAPONS.awp.zoom as number); | |
| 34 | expect(DEFAULT_ZOOM_RATIO).toBe(Math.round(screenShare(TIGHTEST_FOV) * 100) / 100); | |
| 35 | expect(DEFAULT_ZOOM_RATIO).toBeGreaterThanOrEqual(ZOOM_RATIO_MIN); | |
| 36 | expect(DEFAULT_ZOOM_RATIO).toBeLessThan(ZOOM_RATIO_MAX); | |
| 37 | }); | |
| 38 | ||
| 39 | test("the scale is the player's ratio scoped and exactly 1 otherwise", () => { | |
| 40 | expect(lookScale(false, DEFAULT_ZOOM_RATIO)).toBe(1); | |
| 41 | expect(lookScale(false, 0.1)).toBe(1); | |
| 42 | expect(lookScale(true, DEFAULT_ZOOM_RATIO)).toBe(DEFAULT_ZOOM_RATIO); | |
| 43 | // A ratio of 1 is the behaviour the game had before this setting existed. | |
| 44 | expect(lookScale(true, 1)).toBe(1); | |
| 45 | }); |
src/client/zoom.ts added +59
| @@ -0,0 +1,59 @@ | ||
| 1 | /** | |
| 2 | * The scope: the field of view it draws and the share of a turn the mouse is | |
| 3 | * worth while it is up. One module, because those are one decision — a camera | |
| 4 | * that shows less than half as much world while the mouse still sweeps the same | |
| 5 | * degrees is the scoped flick that overshoots. | |
| 6 | * | |
| 7 | * The hip's field of view is written down here and nowhere else; a scope's is | |
| 8 | * the gun's own `zoom` in weapons.ts, beside the rest of its numbers. Every | |
| 9 | * number below is derived from those two, so moving a scope's zoom moves the | |
| 10 | * sensitivity that belongs to it and nothing has to be kept in step by hand. | |
| 11 | */ | |
| 12 | ||
| 13 | import { WEAPONS, type WeaponId } from "./weapons.ts"; | |
| 14 | ||
| 15 | /** The horizontal field of view the camera draws from the hip. */ | |
| 16 | export const HIP_FOV = 90; | |
| 17 | ||
| 18 | /** The field of view a camera draws for this gun: its scope's when it is up. */ | |
| 19 | export function fovFor(weapon: WeaponId, scoped: boolean): number { | |
| 20 | const zoom = WEAPONS[weapon].zoom; | |
| 21 | return scoped && zoom !== undefined ? zoom : HIP_FOV; | |
| 22 | } | |
| 23 | ||
| 24 | /** | |
| 25 | * The share of a hip turn one scoped pixel should be worth for the crosshair | |
| 26 | * to cross the same fraction of the screen. The screen is a plane at | |
| 27 | * tan(fov / 2), so a 40° scope against a 90° hip is tan 20° / tan 45° = 0.364: | |
| 28 | * a flick that crossed a third of the screen unscoped crosses the whole of it | |
| 29 | * scoped, and this is the number that gives the third back. | |
| 30 | */ | |
| 31 | export const screenShare = (fov: number): number => | |
| 32 | Math.tan((fov * Math.PI) / 360) / Math.tan((HIP_FOV * Math.PI) / 360); | |
| 33 | ||
| 34 | /** The tightest scope any gun in the catalogue has — the AWP's 40° today. */ | |
| 35 | export const TIGHTEST_FOV = Math.min( | |
| 36 | HIP_FOV, | |
| 37 | ...Object.values(WEAPONS).map((w) => w.zoom).filter((zoom): zoom is number => zoom !== undefined), | |
| 38 | ); | |
| 39 | ||
| 40 | /** | |
| 41 | * The default ratio: exactly the correction the tightest scope asks for, | |
| 42 | * rounded to the hundredth the settings field steps in. Chosen because it is | |
| 43 | * the one value that is not a taste — it makes a scoped hand move the | |
| 44 | * crosshair across the screen at the speed the same hand moves it unscoped, | |
| 45 | * which is what turns the AWP back into a rifle you can hold still. A player | |
| 46 | * who wants the scope quicker than that raises it; 1 is the sensitivity | |
| 47 | * untouched, the way it behaved before this setting existed. | |
| 48 | */ | |
| 49 | export const DEFAULT_ZOOM_RATIO = Math.round(screenShare(TIGHTEST_FOV) * 100) / 100; | |
| 50 | ||
| 51 | export const ZOOM_RATIO_MIN = 0.1; | |
| 52 | /** Never above 1: the scope is not allowed to be twitchier than the hip. */ | |
| 53 | export const ZOOM_RATIO_MAX = 1; | |
| 54 | ||
| 55 | /** | |
| 56 | * Pure: what a look delta is multiplied by. Unscoped that is 1 and the mouse | |
| 57 | * is the mouse; scoped it is the player's ratio, whatever they set it to. | |
| 58 | */ | |
| 59 | export const lookScale = (scoped: boolean, ratio: number): number => (scoped ? ratio : 1); |
src/pages/game.test.ts +20
| @@ -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_ZOOM_RATIO, HIP_FOV, TIGHTEST_FOV, ZOOM_RATIO_MAX, ZOOM_RATIO_MIN } from "../client/zoom.ts"; | |
| 10 | 11 | import { gamePage, statsHtml } from "./game.ts"; |
| 11 | 12 | |
| 12 | 13 | const db = openDatabase(mkdtempSync(join(tmpdir(), "game-page-"))); |
| @@ -120,6 +121,25 @@ test("the crosshair controls are in the menu and in the pause overlay, one set e | ||
| 120 | 121 | expect(new Set(ids).size).toBe(ids.length); |
| 121 | 122 | }); |
| 122 | 123 | |
| 124 | test("the scope's sensitivity sits beside the look's, in the menu and in the pause overlay", () => { | |
| 125 | const html = gamePage(db, options); | |
| 126 | for (const [overlay, id] of [["menu", "zoom-sens"], ["pause", "pause-zoom-sens"]] as const) { | |
| 127 | const panel = element(html, overlay); | |
| 128 | expect(panel).toContain(`id="${id}"`); | |
| 129 | // A field that could offer a number the parser clamps away would show the | |
| 130 | // player one value and the game another. | |
| 131 | expect(panel).toContain( | |
| 132 | `id="${id}" type="number" min="${ZOOM_RATIO_MIN}" max="${ZOOM_RATIO_MAX}" step="0.01" ` + | |
| 133 | `value="${DEFAULT_ZOOM_RATIO}"`, | |
| 134 | ); | |
| 135 | // Beside the look sensitivity, in the same row: one place for both. | |
| 136 | expect(panel.indexOf(id)).toBeGreaterThan(panel.indexOf("sens")); | |
| 137 | } | |
| 138 | // The page says what the number means and what the FOVs behind it are. | |
| 139 | expect(html).toContain(`${TIGHTEST_FOV}°`); | |
| 140 | expect(html).toContain(`${HIP_FOV}°`); | |
| 141 | }); | |
| 142 | ||
| 123 | 143 | test("a Map select sits beside Difficulty in the menu, offering every catalogued map", () => { |
| 124 | 144 | const menu = element(gamePage(db, options), "menu"); |
| 125 | 145 | expect(menu).toContain(`id="map"`); |
src/pages/game.ts +21 −1
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | |
| 7 | 7 | import type { Database } from "bun:sqlite"; |
| 8 | 8 | import { CROSSHAIR_COLOUR_NAMES, CROSSHAIR_LIMITS } from "../client/crosshair.ts"; |
| 9 | import { DEFAULT_ZOOM_RATIO, HIP_FOV, TIGHTEST_FOV, ZOOM_RATIO_MAX, ZOOM_RATIO_MIN } from "../client/zoom.ts"; | |
| 9 | 10 | import { DEFAULT_MAP, MAP_NAMES } from "../client/map.ts"; |
| 10 | 11 | import { headshotShare, ratio, recentMatches, standings, type MatchRow, type Standing } from "../db.ts"; |
| 11 | 12 | import { isGuest, type Player } from "../player.ts"; |
| @@ -110,6 +111,19 @@ export function crosshairControls(prefix: "menu" | "pause"): string { | ||
| 110 | 111 | </details>`; |
| 111 | 112 | } |
| 112 | 113 | |
| 114 | /** | |
| 115 | * The scope's own sensitivity, rendered once beside the menu's Sensitivity and | |
| 116 | * once beside the pause overlay's — `main.ts` keeps the two in step exactly as | |
| 117 | * it does that pair. The range and the value shown come out of zoom.ts, the one | |
| 118 | * place the scope's field of view and the mouse meet, so this control can never | |
| 119 | * offer a number `parseSettings` would throw away. | |
| 120 | */ | |
| 121 | export function zoomControl(id: "zoom-sens" | "pause-zoom-sens"): string { | |
| 122 | return `<label>Scope sens<input id="${id}" type="number" ` + | |
| 123 | `min="${ZOOM_RATIO_MIN}" max="${ZOOM_RATIO_MAX}" step="0.01" value="${DEFAULT_ZOOM_RATIO}">` + | |
| 124 | `<small>× while scoped</small></label>`; | |
| 125 | } | |
| 126 | ||
| 113 | 127 | const STYLE = ` |
| 114 | 128 | :root { color-scheme: dark; --bg:#0b0d11; --panel:#141821; --line:#232a36; --text:#e7eaf0; --dim:#9aa4b4; --t:#e2b04a; --ct:#6fa8e8; } |
| 115 | 129 | * { box-sizing: border-box; } |
| @@ -288,6 +302,7 @@ export function gamePage(db: Database, options: PageOptions): string { | ||
| 288 | 302 | .map((name) => `<option value="${name}"${name === DEFAULT_MAP ? " selected" : ""}>${name}</option>`) |
| 289 | 303 | .join("")}</select></label> |
| 290 | 304 | <label>Sensitivity<input id="sens" type="number" min="0.2" max="12" step="0.1" value="2.2"></label> |
| 305 | ${zoomControl("zoom-sens")} | |
| 291 | 306 | </div> |
| 292 | 307 | <div class="row"><button type="button" class="primary" id="resume-match" hidden>Resume match</button></div> |
| 293 | 308 | <p class="hint" id="discard-warning" hidden>Starting a new match discards the saved round above.</p> |
| @@ -297,8 +312,12 @@ export function gamePage(db: Database, options: PageOptions): string { | ||
| 297 | 312 | mouse to aim and fire · <kbd>R</kbd> reload · <kbd>E</kbd> plant, defuse or take a gun · <kbd>B</kbd> buy · |
| 298 | 313 | <kbd>Z</kbd> radio · right click or <kbd>Q</kbd> to scope · <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> guns · |
| 299 | 314 | <kbd>4</kbd> the HE grenade, thrown with fire · <kbd>Tab</kbd> scores · |
| 300 | <kbd>Esc</kbd> pauses the match, where sound, sensitivity and the crosshair live too. Every key above is | |
| 315 | <kbd>Esc</kbd> pauses the match, where sound, both sensitivities and the crosshair live too. Every key above is | |
| 301 | 316 | the default — rebind any of them under <b>Controls</b> below.</p> |
| 317 | <p class="hint"><b>Scope sens</b> is what the look is multiplied by while the AWP's scope is up, mouse and thumb alike. | |
| 318 | ${DEFAULT_ZOOM_RATIO} is the default and it is not a taste: the scope draws ${TIGHTEST_FOV}° where the hip | |
| 319 | draws ${HIP_FOV}°, and that number is what makes a flick cross the same share of the screen either way. | |
| 320 | Raise it for a quicker scope; <code>1</code> is the sensitivity untouched.</p> | |
| 302 | 321 | <p class="hint"><kbd>Z</kbd> opens the radio: <b>go A</b>, <b>go B</b> or <b>hold</b>. Your four |
| 303 | 322 | teammates hear it and turn round — a call stands for twenty seconds and then the side goes |
| 304 | 323 | back to the plan it rolled. The other side never hears you.</p> |
| @@ -319,6 +338,7 @@ export function gamePage(db: Database, options: PageOptions): string { | ||
| 319 | 338 | while it comes back; nothing is lost, and Resume lights up when the view does.</p> |
| 320 | 339 | <div class="row"> |
| 321 | 340 | <label>Sensitivity<input id="pause-sens" type="number" min="0.2" max="12" step="0.1"></label> |
| 341 | ${zoomControl("pause-zoom-sens")} | |
| 322 | 342 | <label>Volume<input id="volume" type="range" min="0" max="100" step="1"></label> |
| 323 | 343 | <label>Difficulty<select id="pause-difficulty"> |
| 324 | 344 | <option value="easy">Easy</option> |
src/readme.test.ts +14
| @@ -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 { DEFAULT_ZOOM_RATIO, HIP_FOV, TIGHTEST_FOV } from "./client/zoom.ts"; | |
| 12 | 13 | |
| 13 | 14 | const root = join(import.meta.dir, ".."); |
| 14 | 15 | const read = (path: string): string => readFileSync(join(root, path), "utf8"); |
| @@ -90,6 +91,19 @@ test("the readme says where the crosshair setting is, and that dynamic is the de | ||
| 90 | 91 | for (const name of names) expect(playing).toContain(name); |
| 91 | 92 | }); |
| 92 | 93 | |
| 94 | // The scope's sensitivity is derived, not typed: if the AWP's zoom or the hip's | |
| 95 | // field of view moves, the default moves with it and the README has to say so. | |
| 96 | test("the readme says the scope has its own sensitivity, with the numbers the code derives it from", () => { | |
| 97 | const playing = section("Playing"); | |
| 98 | expect(playing).toContain("**Scope sens**"); | |
| 99 | expect(playing).toContain("`src/client/zoom.ts`"); | |
| 100 | expect(playing).toContain(`\`${DEFAULT_ZOOM_RATIO}\``); | |
| 101 | expect(playing).toContain(`${TIGHTEST_FOV}°`); | |
| 102 | expect(playing).toContain(`${HIP_FOV}°`); | |
| 103 | // And that 1 is the way out, for a player who wants the old feel back. | |
| 104 | expect(playing).toContain("set it to `1`"); | |
| 105 | }); | |
| 106 | ||
| 93 | 107 | test("the readme names every map in the catalogue, and no map the catalogue does not hold", () => { |
| 94 | 108 | const playing = section("Playing"); |
| 95 | 109 | expect(playing).toContain("**There are two maps**"); |
new above
offline