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
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/136-aiming-while-scoped-is-the-same-hand-the → main 1 commit fork
8 files
changed +278
−66
README.md +12 −1
| @@ -208,7 +208,13 @@ flashbang, if you bought them, | ||
| 208 | 208 | <kbd>E</kbd> plants or defuses, <kbd>B</kbd> opens the buy menu in the freeze |
| 209 | 209 | time, <kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> switch guns, <kbd>Tab</kbd> shows |
| 210 | 210 | 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, | |
| 212 | 218 | the right looks around, and the buttons along the bottom do the rest — including |
| 213 | 219 | the one in the top corner, which is full screen and is how you get one on a |
| 214 | 220 | phone, where there is no keyboard and no pointer to lock. A browser that will not |
| @@ -568,6 +574,11 @@ never checked itself. | ||
| 568 | 574 | `palette` (what colour a side is, in the one place both surfaces read it), |
| 569 | 575 | `spectate` (whose eyes a frame |
| 570 | 576 | 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), | |
| 571 | 582 | `audio`, `main`. Everything in here is headless and |
| 572 | 583 | tested: the WebGL2 is the engine's now, and this game has no renderer of its |
| 573 | 584 | 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", | ||
| 242 | 242 | expect(stepsRun(stage)).toBe(12); |
| 243 | 243 | }); |
| 244 | 244 | |
| 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 | ||
| 245 | 290 | test("B opens the buy overlay: it is rebuilt, it lets the pointer go and it takes the focus", () => { |
| 246 | 291 | const stage = playing(); |
| 247 | 292 | stage.lock(true); |
src/client/main.ts +10 −2
| @@ -62,6 +62,7 @@ import { mapFrom, mapLink, nameOf, seedOf, type MapChoice } from "../mapname.ts" | ||
| 62 | 62 | import { matchFrom, playKey } from "../matchid.ts"; |
| 63 | 63 | import { DE_FORGE } from "./map.ts"; |
| 64 | 64 | import type { Level } from "./level.ts"; |
| 65 | import { FOV, fovFor, lookAt } from "./look.ts"; | |
| 65 | 66 | import { COMPETITIVE, SHORT, type Rules } from "./match.ts"; |
| 66 | 67 | import { LAYERS, MATERIALS, TEX_SIZE } from "./materials.ts"; |
| 67 | 68 | import { meshFor } from "./models.ts"; |
| @@ -935,8 +936,15 @@ export function boot(host: Host): Frame { | ||
| 935 | 936 | const me = world.me; |
| 936 | 937 | const [dx, dy] = controls.takeLook(); |
| 937 | 938 | 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); | |
| 940 | 948 | } |
| 941 | 949 | const { input, scoreboard, slotWanted } = readIntent(controls.held, controls.stick); |
| 942 | 950 | input.switchTo = |
src/client/spectate.ts +5 −5
| @@ -29,6 +29,7 @@ | ||
| 29 | 29 | */ |
| 30 | 30 | |
| 31 | 31 | import { angleDelta, clamp, eyeHeight, type Camera } from "../engine/index.ts"; |
| 32 | import { fovFor } from "./look.ts"; | |
| 32 | 33 | import { CS_HULL } from "./movement.ts"; |
| 33 | 34 | import type { Player, World } from "./world.ts"; |
| 34 | 35 | |
| @@ -52,10 +53,6 @@ export const FOV_TAU = 0.05; | ||
| 52 | 53 | */ |
| 53 | 54 | export const CUT_DISTANCE = 128; |
| 54 | 55 | |
| 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 | ||
| 59 | 56 | /** The frame the camera is willing to catch up over. A slept tab is a cut anyway. */ |
| 60 | 57 | const MAX_DT = 0.25; |
| 61 | 58 | |
| @@ -156,7 +153,10 @@ export function follow(world: World, spectator: Spectator, camera: Camera, dt: n | ||
| 156 | 153 | // watching; it is eased with everything else rather than shaken in raw. |
| 157 | 154 | const yaw = view.yaw - view.punch[0] * 0.4; |
| 158 | 155 | 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); | |
| 160 | 160 | |
| 161 | 161 | const jumped = Math.hypot(x - camera.pos[0], y - camera.pos[1], z - camera.pos[2]) > CUT_DISTANCE; |
| 162 | 162 | // 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 filestatic/app.df3a822521.js added +58
Diff not shown — a line 69,802 characters wide (limit 2,000). +58
View file
new above
offline