Public: anyone can read this; the crew can change it.

forked from trevato/cs

Aiming while scoped is the same hand: the sensitivity scales with the zoom, the way Counter-Strike's does #231

Merged opened this

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

Merged

torque/cs-engine:torque/136-aiming-while-scoped-is-the-same-hand-the → main 1 commit fork

  • Merged. torque/136-aiming-while-scoped-is-the-same-hand-the was merged into main as 04b26f3.

Review

Sign in to review this change.
8 files changed +278 −66
README.md +12 −1
@@ -208,7 +208,13 @@ flashbang, if you bought them,
208208 <kbd>E</kbd> plants or defuses, <kbd>B</kbd> opens the buy menu in the freeze
209209 time, <kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> switch guns, <kbd>Tab</kbd> shows
210210 the scoreboard, <kbd>F</kbd> puts the stage on the whole screen and
211 <kbd>Esc</kbd> or <kbd>P</kbd> pauses. On a touch screen the left of the view is a thumb stick,
211 <kbd>Esc</kbd> or <kbd>P</kbd> pauses. Right mouse pulls up the scope on the one
212 gun that has one, and **the mouse slows down with the zoom**: the view narrows
213 from 90° to 40° across, and the look is scaled by `tan(40°/2) / tan(90°/2)` =
214 0.364, so the same hand movement crosses the same fraction of the screen scoped
215 as unscoped — CS's `zoom_sensitivity_ratio` at 1, and `src/client/look.ts` is
216 the whole of it. Unscoped, the Sensitivity box on the menu means exactly what it
217 says: at the default view that scale is 1 and the number is untouched. On a touch screen the left of the view is a thumb stick,
212218 the right looks around, and the buttons along the bottom do the rest — including
213219 the one in the top corner, which is full screen and is how you get one on a
214220 phone, where there is no keyboard and no pointer to lock. A browser that will not
@@ -568,6 +574,11 @@ never checked itself.
568574 `palette` (what colour a side is, in the one place both surfaces read it),
569575 `spectate` (whose eyes a frame
570576 is drawn from once you are dead, and the camera that eases onto them),
577 `look` (the two fields of view the game is seen through — 90° and the scope's
578 40° — and `zoomScale`/`lookAt`, which turn a pointer movement into the degrees
579 it is worth at the one you are looking through; the engine's controls hand
580 back degrees and know nothing about scopes, so the compensation is applied
581 where `main` reads the look),
571582 `audio`, `main`. Everything in here is headless and
572583 tested: the WebGL2 is the engine's now, and this game has no renderer of its
573584 own. The level is handed in rather than imported: `createWorld(level, …)`
src/client/look.test.ts added +84
@@ -0,0 +1,84 @@
1 /**
2 * The zoom compensation, against a hand calculation.
3 *
4 * The numbers below are worked out here rather than recomputed from the source
5 * formula, which would only prove the code equals itself:
6 *
7 * tan(20°) = 0.3639702342662024, tan(45°) = 1 (to a bit: 0.9999999999999999)
8 * scale = tan(20°) / tan(45°) = 0.3639702342662024
9 *
10 * and a player at 2.2° of turn per count who moves the mouse 10 counts asks for
11 * 22.0° of yaw unscoped, so scoped they get
12 *
13 * 22.0 × 0.3639702342662024 = 8.007345153856452°
14 *
15 * which is the same fraction of the screen: 22° is 22/90 = 24.4% of the
16 * unscoped view's width in *angle*, but the screen is a tangent picture, and
17 * what matches at both fields of view is the distance the crosshair moves
18 * across it — checked below by projecting both turns onto their own screen.
19 */
20
21 import { expect, test } from "bun:test";
22 import { FOV, fovFor, lookAt, SCOPED_FOV, zoomScale } from "./look.ts";
23
24 /** Degrees of turn per count, the menu's default and the number in the sums above. */
25 const SENSITIVITY = 2.2;
26
27 test("the default view is the sensitivity the player set, to the bit", () => {
28 expect(zoomScale(FOV, FOV)).toBe(1);
29 for (const delta of [0.123456789, -33.75, 1e-9, 1234.5678, 0]) {
30 expect(lookAt(delta, FOV, FOV)).toBe(delta);
31 expect(lookAt(delta, fovFor(false), FOV)).toBe(delta);
32 }
33 });
34
35 test("the scope scales the turn by tan(fov/2) / tan(fovDefault/2)", () => {
36 expect(zoomScale(SCOPED_FOV, FOV)).toBeCloseTo(0.3639702342662024, 15);
37 // 10 counts × 2.2° each = 22°, and through the scope 8.0073°.
38 const asked = 10 * SENSITIVITY;
39 expect(lookAt(asked, FOV, FOV)).toBe(22);
40 expect(lookAt(asked, SCOPED_FOV, FOV)).toBeCloseTo(8.007345153856452, 12);
41 // The sign is the delta's: a turn left through a scope is still a turn left.
42 expect(lookAt(-asked, SCOPED_FOV, FOV)).toBeCloseTo(-8.007345153856452, 12);
43 // Twitchier, not calmer, was the bug: the scoped turn is under a third of
44 // the way to the unscoped one, not more than twice as far.
45 expect(lookAt(asked, SCOPED_FOV, FOV)).toBeLessThan(asked);
46 });
47
48 test("the same hand movement crosses the same fraction of the screen at either view", () => {
49 // Where a turn of `deg` puts the crosshair on a screen of half-width
50 // tan(fov/2), as a fraction of that half-width: tan(deg) / tan(fov/2).
51 const acrossScreen = (deg: number, fov: number): number =>
52 Math.tan((deg * Math.PI) / 180) / Math.tan((fov * Math.PI) / 360);
53
54 // Small movements, where the tangent is near enough its angle that the ratio
55 // of half-widths is the right scale: a quarter-count to two counts.
56 for (const counts of [0.25, 0.5, 1, 2]) {
57 const asked = counts * SENSITIVITY;
58 const plain = acrossScreen(lookAt(asked, FOV, FOV), FOV);
59 const scoped = acrossScreen(lookAt(asked, SCOPED_FOV, FOV), SCOPED_FOV);
60 expect(scoped).toBeCloseTo(plain, 3);
61 }
62 // Without the scale it is the bug the ratio is for: the same 22° asked for
63 // at 40° of view crosses 2.75 times as much screen as it does at 90°.
64 expect(acrossScreen(22, SCOPED_FOV) / acrossScreen(22, FOV)).toBeCloseTo(2.747, 3);
65 });
66
67 test("it takes a field of view, not a flag, so a second zoom level is one number", () => {
68 // A hypothetical second scope at 15°: nothing here has to change to scale it.
69 // tan(7.5°) / tan(45°) = 0.13165249758739583.
70 expect(zoomScale(15, FOV)).toBeCloseTo(0.13165249758739583, 15);
71 expect(lookAt(22, 15, FOV)).toBeCloseTo(2.8963549469227083, 12);
72 // Narrower is always slower, and the scale rises with the field of view.
73 expect(zoomScale(15, FOV)).toBeLessThan(zoomScale(SCOPED_FOV, FOV));
74 expect(zoomScale(SCOPED_FOV, FOV)).toBeLessThan(zoomScale(FOV, FOV));
75 // And a wider view than the default is faster, which is the same formula
76 // read the other way — a 120° view is tan(60°)/tan(45°) = 1.732.
77 expect(zoomScale(120, FOV)).toBeCloseTo(Math.sqrt(3), 12);
78 });
79
80 test("the fields of view are the ones the camera uses", () => {
81 expect(fovFor(true)).toBe(SCOPED_FOV);
82 expect(fovFor(false)).toBe(FOV);
83 expect([SCOPED_FOV, FOV]).toEqual([40, 90]);
84 });
src/client/look.ts added +64
@@ -0,0 +1,64 @@
1 /**
2 * The look: the two fields of view this game is seen through, and what a
3 * pointer movement is worth at each of them.
4 *
5 * The engine's controls hand back a turn in degrees, already scaled by the
6 * player's sensitivity. They know nothing about scopes and must not — a scope
7 * is this shooter's tuning, so the compensation lives here, beside the game's
8 * other numbers, and is applied where the look is read.
9 *
10 * Pulling up the scope narrows the view from 90° to 40° across. With the same
11 * degrees per count, the same hand movement then crosses more than twice as
12 * much of the screen as it did unscoped — twitchy at exactly the moment a
13 * player wants the opposite. Counter-Strike scales the sensitivity with the
14 * zoom (`zoom_sensitivity_ratio`, 1.0 by default, which is this); `zoomScale`
15 * below is that scale and `lookAt` is a turn taken through it.
16 */
17
18 /** The field of view the game is played at: degrees, measured across the screen. */
19 export const FOV = 90;
20
21 /**
22 * The one a scope brings on, in degrees across — the same 40 the AWP declares
23 * as its `zoom` in `weapons.ts`, and the one `spectate.ts` eases the camera to.
24 * A second zoom level is one more constant here and nothing else: everything
25 * below takes a field of view, never a "scoped" flag, so a scope that is not
26 * this one is a number passed to the camera and to `lookAt` together.
27 */
28 export const SCOPED_FOV = 40;
29
30 /** Which of the two you are looking through. Degrees out, never a boolean on. */
31 export const fovFor = (scoped: boolean): number => (scoped ? SCOPED_FOV : FOV);
32
33 /**
34 * How much of a turn survives the zoom — unitless, 1 at the default view:
35 *
36 * scale = tan(fov / 2) / tan(fovDefault / 2)
37 *
38 * A half-angle's tangent is the half-width of what the screen shows at one unit
39 * of depth, so the ratio is how much less world the zoomed screen holds. Turn
40 * by that fraction of the degrees and a hand movement crosses the same fraction
41 * of the screen at either field of view, which is the thing a player's muscle
42 * memory is actually measured in. At 40° and 90°: tan20° / tan45° = 0.36397, so
43 * a scoped turn is a little over a third of the unscoped one.
44 *
45 * Both angles are horizontal — the renderer derives the vertical one from the
46 * aspect — so the same scale is right for pitch as for yaw.
47 *
48 * `fov` and `fovDefault` are degrees in (0, 180); the scale is a pure number.
49 */
50 export const zoomScale = (fov: number, fovDefault: number): number =>
51 Math.tan((fov * Math.PI) / 360) / Math.tan((fovDefault * Math.PI) / 360);
52
53 /**
54 * The turn to take this frame: `delta` degrees of look asked for at the
55 * player's sensitivity, taken through `fov` instead of the `fovDefault` that
56 * sensitivity was set at. Degrees in, degrees out.
57 *
58 * At the default view this is the delta itself to the bit — `t / t` is exactly
59 * 1 in IEEE 754 for any finite non-zero `t`, and a multiply by exactly 1 moves
60 * nothing — so a player's sensitivity setting still means what it says when
61 * they are not scoped.
62 */
63 export const lookAt = (delta: number, fov: number, fovDefault: number): number =>
64 delta * zoomScale(fov, fovDefault);
src/client/main.test.ts +45
@@ -242,6 +242,51 @@ test("one frame of 1/30 s runs two 64 Hz steps and no more, and banks the rest",
242242 expect(stepsRun(stage)).toBe(12);
243243 });
244244
245 test("the mouse turns the view, and while scoped it turns it by the zoom's share", () => {
246 const stage = playing();
247 stage.lock(true);
248 const world = (stage.step(FRAME), worldOf(stage)) as World;
249 world.match.phase = "live";
250 world.match.timer = 60;
251 // The one gun in this game with a scope, in this player's hands. Right mouse
252 // is bound to "scope" in `binding.ts`, and the world only listens to it for a
253 // weapon that has a `zoom`.
254 world.me.primary = "awp";
255 world.me.weapon = "awp";
256
257 // 10 counts × 2.2 sensitivity × 0.022° each: 0.484° of yaw, and moving right
258 // turns it down. This is the unscoped path, unchanged by #136.
259 const start = world.me.yaw;
260 stage.window.fire("mousemove", { movementX: 10, movementY: 0 });
261 stage.step(FRAME);
262 expect(world.me.scoped).toBe(false);
263 expect(world.me.yaw).toBeCloseTo(start - 0.484, 9);
264
265 // Scoped, by the button a player actually presses: this frame's steps set it.
266 stage.scene.fire("mousedown", { button: 2 });
267 stage.step(FRAME);
268 expect(world.me.scoped).toBe(true);
269
270 // The same hand movement through 40° of view instead of 90°:
271 // 0.484° × tan(20°)/tan(45°) = 0.17616°, so the crosshair crosses the same
272 // fraction of the screen it did unscoped rather than 2.7 times as much.
273 const scopedFrom = world.me.yaw;
274 stage.window.fire("mousemove", { movementX: 10, movementY: 0 });
275 stage.step(FRAME);
276 expect(world.me.yaw).toBeCloseTo(scopedFrom - 0.17616159338484194, 9);
277
278 // Letting the button go unscopes on that frame's steps, and the next movement
279 // is the plain sensitivity again — the view itself never jumps, because all
280 // any of this scales is the delta a mouse asked for.
281 stage.window.fire("mouseup", { button: 2 });
282 stage.step(FRAME);
283 expect(world.me.scoped).toBe(false);
284 const plainFrom = world.me.yaw;
285 stage.window.fire("mousemove", { movementX: 10, movementY: 0 });
286 stage.step(FRAME);
287 expect(world.me.yaw).toBeCloseTo(plainFrom - 0.484, 9);
288 });
289
245290 test("B opens the buy overlay: it is rebuilt, it lets the pointer go and it takes the focus", () => {
246291 const stage = playing();
247292 stage.lock(true);
src/client/main.ts +10 −2
@@ -62,6 +62,7 @@ import { mapFrom, mapLink, nameOf, seedOf, type MapChoice } from "../mapname.ts"
6262 import { matchFrom, playKey } from "../matchid.ts";
6363 import { DE_FORGE } from "./map.ts";
6464 import type { Level } from "./level.ts";
65 import { FOV, fovFor, lookAt } from "./look.ts";
6566 import { COMPETITIVE, SHORT, type Rules } from "./match.ts";
6667 import { LAYERS, MATERIALS, TEX_SIZE } from "./materials.ts";
6768 import { meshFor } from "./models.ts";
@@ -935,8 +936,15 @@ export function boot(host: Host): Frame {
935936 const me = world.me;
936937 const [dx, dy] = controls.takeLook();
937938 if (me.alive && controls.locked) {
938 me.yaw -= dx;
939 me.pitch = clamp(me.pitch + dy, -89, 89);
939 // The engine's degrees, taken through whatever this body is looking out
940 // of: scoped, 40° of world on the screen instead of 90° means a hand
941 // movement worth `tan(20°) / tan(45°)` of the turn it is worth unscoped,
942 // so it crosses the same fraction of the screen either way. `me.scoped`
943 // is what the last frame's steps left, which is the state the mouse
944 // moved under; unscoped the scale is exactly 1 and `dx` is untouched.
945 const fov = fovFor(me.scoped);
946 me.yaw -= lookAt(dx, fov, FOV);
947 me.pitch = clamp(me.pitch + lookAt(dy, fov, FOV), -89, 89);
940948 }
941949 const { input, scoreboard, slotWanted } = readIntent(controls.held, controls.stick);
942950 input.switchTo =
src/client/spectate.ts +5 −5
@@ -29,6 +29,7 @@
2929 */
3030
3131 import { angleDelta, clamp, eyeHeight, type Camera } from "../engine/index.ts";
32 import { fovFor } from "./look.ts";
3233 import { CS_HULL } from "./movement.ts";
3334 import type { Player, World } from "./world.ts";
3435
@@ -52,10 +53,6 @@ export const FOV_TAU = 0.05;
5253 */
5354 export const CUT_DISTANCE = 128;
5455
55 /** The field of view a scope brings on, and the one the game is played at. */
56 const SCOPED_FOV = 40;
57 const FOV = 90;
58
5956 /** The frame the camera is willing to catch up over. A slept tab is a cut anyway. */
6057 const MAX_DT = 0.25;
6158
@@ -156,7 +153,10 @@ export function follow(world: World, spectator: Spectator, camera: Camera, dt: n
156153 // watching; it is eased with everything else rather than shaken in raw.
157154 const yaw = view.yaw - view.punch[0] * 0.4;
158155 const pitch = view.pitch - view.punch[1] * 0.4;
159 const fov = view.scoped ? SCOPED_FOV : FOV;
156 // The field of view a scope brings on, and the one the game is played at,
157 // are `look.ts`'s numbers: the camera eases to whichever this body is under,
158 // and the same pair is what the mouse is scaled by while you are in it.
159 const fov = fovFor(view.scoped);
160160
161161 const jumped = Math.hypot(x - camera.pos[0], y - camera.pos[1], z - camera.pos[2]) > CUT_DISTANCE;
162162 // Your own head is never eased: that would be input lag on your own mouse.
static/app.b71cd5e5b8.js deleted −58

Diff not shown — a line 69,665 characters wide (limit 2,000). −58

View file
static/app.df3a822521.js added +58

Diff not shown — a line 69,802 characters wide (limit 2,000). +58

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs