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

Merged opened this

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

Merged

vesper/cs:vesper/182-the-awp-s-scope-zooms-the-view-but-not-t → main 1 commit fork

  • Merged. vesper/182-the-awp-s-scope-zooms-the-view-but-not-t was merged into main as 441f207.

Review

Sign in to review this change.
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
185185 out the HE grenade if you bought one (fire throws it, and the hand goes back to
186186 the gun once it is gone), <kbd>Z</kbd> opens the radio, <kbd>Tab</kbd> shows
187187 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.
190207
191208 Every one of those keys is a default, not a floor: **WASD is not everyone's
192209 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 @@
66 */
77
88 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";
1013 import { downAt } from "./press.ts";
14 import { DEFAULT_ZOOM_RATIO } from "./zoom.ts";
1115
1216 type Listener = (event: unknown) => void;
1317
@@ -128,6 +132,57 @@ test("the left third is still the stick, and a second finger there does not fire
128132 expect(controls.inputAt(controls.take(1), 0).side).toBe(0);
129133 });
130134
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
131186 // --- the buttons on the pad, which are the same action names as the keys
132187
133188 /** A fake `[data-action]` button, and a root that hands it to attachButtons. */
src/client/input.ts +44 −6
@@ -8,13 +8,36 @@
88 * fires, exactly once.
99 *
1010 * `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.
1315 */
1416
1517 import { clamp } from "./math.ts";
1618 import { createPress, downAt, type Frame } from "./press.ts";
1719 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 }
1841
1942 /** The controls a press of counts even if the release beat the next frame. */
2043 export const LATCHED = ["fire", "jump", "use", "reload"];
@@ -29,6 +52,14 @@ export type Controls = {
2952 /** 1 primary, 2 pistol, 3 knife, 4 grenade — main knows what they are. */
3053 readonly slotWanted: 1 | 2 | 3 | 4 | null;
3154 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;
3263 locked: boolean;
3364 setAction(action: string, down: boolean): void;
3465 /** 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
87118
88119 export function createControls(
89120 canvas: HTMLCanvasElement, sensitivity = 2.2, bindings: Record<Action, string> = DEFAULT_BINDINGS,
121 zoomRatio = DEFAULT_ZOOM_RATIO,
90122 ): Controls {
91123 const press = createPress(LATCHED);
92124 const input: Input = { ...NO_INPUT };
@@ -124,6 +156,8 @@ export function createControls(
124156 : press.isDown("slot2") ? 2 : press.isDown("slot1") ? 1 : null;
125157 },
126158 sensitivity,
159 zoomRatio,
160 scoped: false,
127161 locked: false,
128162 setAction: (action, down) => {
129163 if (down) press.down(action);
@@ -197,8 +231,8 @@ export function createControls(
197231 canvas.addEventListener("contextmenu", (event) => event.preventDefault());
198232 window.addEventListener("mousemove", (event) => {
199233 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);
202236 });
203237 window.addEventListener("blur", () => {
204238 controls.clear();
@@ -237,8 +271,12 @@ export function createControls(
237271 stick.x = clamp((touch.clientX - state.startX) / 60, -1, 1);
238272 stick.y = clamp((state.startY - touch.clientY) / 60, -1, 1);
239273 } 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);
242280 }
243281 state.x = touch.clientX;
244282 state.y = touch.clientY;
src/client/main.ts +31 −3
@@ -25,7 +25,9 @@ import { createRenderer, type Camera } from "./renderer.ts";
2525 import type { CrosshairStyle } from "./crosshair.ts";
2626 import {
2727 DEFAULT_SETTINGS, loadSettings, parseCrosshair, saveSettings, SENSITIVITY_MAX, SENSITIVITY_MIN,
28 ZOOM_RATIO_MAX, ZOOM_RATIO_MIN,
2829 } from "./settings.ts";
30 import { fovFor } from "./zoom.ts";
2931 import { CALL_LIFE, isCall, type Call } from "./plan.ts";
3032 import { clearSaved, readSaved, resumeLine, snapshot, writeSaved } from "./saved.ts";
3133 import { nextWatched, watched } from "./spectate.ts";
@@ -68,6 +70,8 @@ function boot(): void {
6870 const discardWarning = $("discard-warning");
6971 const sensField = $<HTMLInputElement>("sens");
7072 const pauseSensField = $<HTMLInputElement>("pause-sens");
73 const zoomField = $<HTMLInputElement>("zoom-sens");
74 const pauseZoomField = $<HTMLInputElement>("pause-zoom-sens");
7175 const volumeField = $<HTMLInputElement>("volume");
7276 const muteButton = $<HTMLButtonElement>("mute");
7377 const fullscreenMenuButton = $<HTMLButtonElement>("fullscreen-menu");
@@ -103,7 +107,7 @@ function boot(): void {
103107 // click is already in force once the context exists to hear it.
104108 audio.setVolume(settings.volume);
105109 audio.setMuted(settings.muted);
106 const controls = createControls(scene, settings.sensitivity, settings.bindings);
110 const controls = createControls(scene, settings.sensitivity, settings.bindings, settings.zoomRatio);
107111 attachButtons(document, controls);
108112
109113 // The menu's sensitivity field and the pause overlay's are the same number:
@@ -118,6 +122,19 @@ function boot(): void {
118122 pauseSensField.value = String(next);
119123 saveSettings(settings);
120124 };
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 };
121138 // Same pairing as sensitivity: the menu's field picks it before a match, the
122139 // pause overlay's changes it mid-match — but `setDifficulty` only queues the
123140 // change for the next round, so a bot already in a fight keeps its hands.
@@ -147,6 +164,8 @@ function boot(): void {
147164 };
148165 sensField.value = String(settings.sensitivity);
149166 pauseSensField.value = String(settings.sensitivity);
167 zoomField.value = String(settings.zoomRatio);
168 pauseZoomField.value = String(settings.zoomRatio);
150169 volumeField.value = String(Math.round(settings.volume * 100));
151170 difficultyField.value = settings.difficulty;
152171 pauseDifficultyField.value = settings.difficulty;
@@ -157,6 +176,8 @@ function boot(): void {
157176 // is not a value to clamp and snap back on every keystroke.
158177 sensField.addEventListener("change", () => syncSensitivity(Number(sensField.value)));
159178 pauseSensField.addEventListener("change", () => syncSensitivity(Number(pauseSensField.value)));
179 zoomField.addEventListener("change", () => syncZoomRatio(Number(zoomField.value)));
180 pauseZoomField.addEventListener("change", () => syncZoomRatio(Number(pauseZoomField.value)));
160181 difficultyField.addEventListener("change", () => syncDifficulty(difficultyField.value));
161182 pauseDifficultyField.addEventListener("change", () => syncDifficulty(pauseDifficultyField.value));
162183 mapField.addEventListener("change", () => syncMap(mapField.value));
@@ -174,7 +195,9 @@ function boot(): void {
174195 });
175196 // The pause dialog sits over a live match: a key typed into one of its
176197 // 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 ])
178201 field.addEventListener("keydown", (event) => event.stopPropagation());
179202
180203 // --- the crosshair: the same six controls in the menu and in the pause
@@ -637,6 +660,8 @@ function boot(): void {
637660 savedRound = 0;
638661 watching = world.me.id;
639662 controls.sensitivity = settings.sensitivity;
663 controls.zoomRatio = settings.zoomRatio;
664 controls.scoped = false;
640665 running = true;
641666 lastSound = 0;
642667 show(menu, false);
@@ -811,6 +836,9 @@ function boot(): void {
811836 update(world, TICK, input);
812837 }
813838 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;
814842 feelEffects(world, watched(world, watching));
815843
816844 // The start of every freeze time, fresh round or resumed match alike:
@@ -850,7 +878,7 @@ function boot(): void {
850878 pos: eyePos(view),
851879 yaw: view.yaw - view.punch[0] * 0.4,
852880 pitch: view.pitch - view.punch[1] * 0.4,
853 fov: view.scoped ? 40 : 90,
881 fov: fovFor(view.weapon, view.scoped),
854882 };
855883 // Pixels are the cheapest thing to give up, so give them up on a timer and
856884 // 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";
88 import { DEFAULT_CROSSHAIR } from "./crosshair.ts";
99 import { DEFAULT_BINDINGS } from "./input.ts";
1010 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";
1215
1316 test("nothing stored yet is the default", () => {
1417 expect(parseSettings(null)).toEqual(DEFAULT_SETTINGS);
@@ -26,12 +29,12 @@ test("a JSON value that isn't an object is the default", () => {
2629 });
2730
2831 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",
3134 crosshair: DEFAULT_CROSSHAIR, bindings: DEFAULT_BINDINGS,
3235 });
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",
3538 crosshair: DEFAULT_CROSSHAIR, bindings: DEFAULT_BINDINGS,
3639 });
3740 });
@@ -51,11 +54,32 @@ test("a whole, valid settings object round-trips", () => {
5154 const bindings = { ...DEFAULT_BINDINGS, forward: "KeyG" };
5255 const crosshair = { colour: "cyan", size: 12, gap: 2, thickness: 3, dot: true, dynamic: false } as const;
5356 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,
5559 };
5660 expect(parseSettings(JSON.stringify(stored))).toEqual(stored);
5761 });
5862
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
5983 test("a settings blob written before the crosshair existed still loads, with today's crosshair", () => {
6084 // Exactly what `saveSettings` wrote yesterday: no `crosshair` key at all.
6185 const old = '{"muted":true,"volume":0.4,"sensitivity":3.5,"difficulty":"hard",' +
src/client/settings.ts +12 −4
@@ -1,7 +1,7 @@
11 /**
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`.
55 * `parseSettings` is the boundary — a missing, malformed, out-of-range or
66 * unknown value falls back to the default and never puts NaN, an
77 * unrecognised string or an unbound action into the game. What a difficulty
@@ -16,11 +16,14 @@ import {
1616 } from "./crosshair.ts";
1717 import { ACTIONS, DEFAULT_BINDINGS, type Action } from "./input.ts";
1818 import { DEFAULT_MAP, MAP_NAMES, type MapName } from "./map.ts";
19 import { DEFAULT_ZOOM_RATIO, ZOOM_RATIO_MAX, ZOOM_RATIO_MIN } from "./zoom.ts";
1920
2021 export type Settings = {
2122 muted: boolean;
2223 volume: number;
2324 sensitivity: number;
25 /** What the look sensitivity is multiplied by while a scope is up. */
26 zoomRatio: number;
2427 difficulty: Difficulty;
2528 map: MapName;
2629 crosshair: CrosshairStyle;
@@ -28,12 +31,16 @@ export type Settings = {
2831 };
2932
3033 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,
3236 crosshair: DEFAULT_CROSSHAIR,
3337 bindings: DEFAULT_BINDINGS,
3438 };
3539 export const SENSITIVITY_MIN = 0.2;
3640 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 };
3744
3845 const isDifficulty = (value: unknown): value is Difficulty =>
3946 typeof value === "string" && (DIFFICULTIES as readonly string[]).includes(value);
@@ -123,6 +130,7 @@ export function parseSettings(raw: string | null): Settings {
123130 muted: obj.muted === true,
124131 volume: bounded(obj.volume, 0, 1, DEFAULT_SETTINGS.volume),
125132 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),
126134 difficulty: isDifficulty(obj.difficulty) ? obj.difficulty : DEFAULT_SETTINGS.difficulty,
127135 map: isMapName(obj.map) ? obj.map : DEFAULT_SETTINGS.map,
128136 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";
77 import type { Result } from "../result.ts";
88 import { CROSSHAIR_COLOUR_NAMES, CROSSHAIR_LIMITS } from "../client/crosshair.ts";
99 import { DEFAULT_MAP, MAP_NAMES } from "../client/map.ts";
10 import { DEFAULT_ZOOM_RATIO, HIP_FOV, TIGHTEST_FOV, ZOOM_RATIO_MAX, ZOOM_RATIO_MIN } from "../client/zoom.ts";
1011 import { gamePage, statsHtml } from "./game.ts";
1112
1213 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
120121 expect(new Set(ids).size).toBe(ids.length);
121122 });
122123
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
123143 test("a Map select sits beside Difficulty in the menu, offering every catalogued map", () => {
124144 const menu = element(gamePage(db, options), "menu");
125145 expect(menu).toContain(`id="map"`);
src/pages/game.ts +21 −1
@@ -6,6 +6,7 @@
66
77 import type { Database } from "bun:sqlite";
88 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";
910 import { DEFAULT_MAP, MAP_NAMES } from "../client/map.ts";
1011 import { headshotShare, ratio, recentMatches, standings, type MatchRow, type Standing } from "../db.ts";
1112 import { isGuest, type Player } from "../player.ts";
@@ -110,6 +111,19 @@ export function crosshairControls(prefix: "menu" | "pause"): string {
110111 </details>`;
111112 }
112113
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
113127 const STYLE = `
114128 :root { color-scheme: dark; --bg:#0b0d11; --panel:#141821; --line:#232a36; --text:#e7eaf0; --dim:#9aa4b4; --t:#e2b04a; --ct:#6fa8e8; }
115129 * { box-sizing: border-box; }
@@ -288,6 +302,7 @@ export function gamePage(db: Database, options: PageOptions): string {
288302 .map((name) => `<option value="${name}"${name === DEFAULT_MAP ? " selected" : ""}>${name}</option>`)
289303 .join("")}</select></label>
290304 <label>Sensitivity<input id="sens" type="number" min="0.2" max="12" step="0.1" value="2.2"></label>
305 ${zoomControl("zoom-sens")}
291306 </div>
292307 <div class="row"><button type="button" class="primary" id="resume-match" hidden>Resume match</button></div>
293308 <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 {
297312 mouse to aim and fire · <kbd>R</kbd> reload · <kbd>E</kbd> plant, defuse or take a gun · <kbd>B</kbd> buy ·
298313 <kbd>Z</kbd> radio · right click or <kbd>Q</kbd> to scope · <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> guns ·
299314 <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
301316 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>
302321 <p class="hint"><kbd>Z</kbd> opens the radio: <b>go A</b>, <b>go B</b> or <b>hold</b>. Your four
303322 teammates hear it and turn round — a call stands for twenty seconds and then the side goes
304323 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 {
319338 while it comes back; nothing is lost, and Resume lights up when the view does.</p>
320339 <div class="row">
321340 <label>Sensitivity<input id="pause-sens" type="number" min="0.2" max="12" step="0.1"></label>
341 ${zoomControl("pause-zoom-sens")}
322342 <label>Volume<input id="volume" type="range" min="0" max="100" step="1"></label>
323343 <label>Difficulty<select id="pause-difficulty">
324344 <option value="easy">Easy</option>
src/readme.test.ts +14
@@ -9,6 +9,7 @@ import { expect, test } from "bun:test";
99 import { readFileSync } from "node:fs";
1010 import { join } from "node:path";
1111 import { DEFAULT_MAP, MAP_NAMES } from "./client/map.ts";
12 import { DEFAULT_ZOOM_RATIO, HIP_FOV, TIGHTEST_FOV } from "./client/zoom.ts";
1213
1314 const root = join(import.meta.dir, "..");
1415 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
9091 for (const name of names) expect(playing).toContain(name);
9192 });
9293
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
93107 test("the readme names every map in the catalogue, and no map the catalogue does not hold", () => {
94108 const playing = section("Playing");
95109 expect(playing).toContain("**There are two maps**");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs