Split The code is public; the app answers only to the crew.

A thumb aims with the mouse's sensitivity: touch look has no setting of its own #229

Merged opened this

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

Merged

scout/cs:scout/191-a-thumb-aims-with-the-mouse-s-sensitivit → main 1 commit fork

  • Merged. scout/191-a-thumb-aims-with-the-mouse-s-sensitivit was merged into main as 4ac6ee4.

Review

Sign in to review this change.
7 files changed +142 −17
README.md +18 −4
@@ -203,8 +203,9 @@ out the HE grenade if you bought one (fire throws it, and the hand goes back to
203203 the gun once it is gone), <kbd>Z</kbd> opens the radio, <kbd>Tab</kbd> shows
204204 the scoreboard, <kbd>Esc</kbd> pauses the match on a desktop and `menu` on the
205205 pad does it on a phone. Pausing also opens mute, volume,
206 look sensitivity and the scope's own share of it, which take effect at once and
207 are remembered for next time.
206 look sensitivity and the scope's own share of it — and on a phone, the thumb's
207 own sensitivity too — which take effect at once and are remembered for next
208 time.
208209
209210 **A gun has to come up before it fires.** Every weapon has a draw — the knife
210211 0.4 s, a pistol 0.5–0.6 s, a rifle 1 s, the AWP 1.25 s — and for that long
@@ -239,6 +240,19 @@ slower one, or set it to `1` for the old behaviour where the scope turns exactly
239240 as fast as the hip. It is the person's view only: no bot's turn rate moves with
240241 it, and nothing the policy sees changes.
241242
243 **A thumb has its own sensitivity, apart from the mouse's.** `Sensitivity` above
244 used to be read for a touch drag too, so tuning the phone's aim for a 360px
245 screen detuned the mouse and back again — the two devices never wanted the same
246 number. `Touch sens` is stored and clamped exactly like `Sensitivity` (in
247 `src/client/settings.ts`) and reaches only a touch whose role is `look` — the
248 `Controls.touchSensitivity` in `src/client/input.ts`, run through the same
249 `lookDelta` the mouse and the scope go through, so the stick's 60px throw and
250 the look pipeline's units never move. It lives in the pause overlay only, next
251 to `Sensitivity`, and only where the browser reports a coarse pointer: a
252 mouse-and-keyboard page never grows a field for a thumb it does not have. Its
253 default is `2.2`, the mouse's own default, so nothing changes underfoot until
254 you touch the new number yourself.
255
242256 Every one of those keys is a default, not a floor: **WASD is not everyone's
243257 WASD** — on an AZERTY board, a Dvorak layout or just a different habit, the
244258 letters printed on the physical keys are not where this game's defaults
@@ -436,7 +450,7 @@ On a touch screen the same actions are a pad, and the view is the aim:
436450 | what | key | thumb |
437451 | --- | --- | --- |
438452 | move | <kbd>WASD</kbd> | the left third of the view is a stick |
439 | aim | mouse | drag anywhere on the rest of the view |
453 | aim | mouse | drag anywhere on the rest of the view, at `Touch sens` |
440454 | fire | mouse | **a second finger on the right of the view**, or the fire bar |
441455 | scope (AWP only) | right click or <kbd>Q</kbd> | `scope`, held like the mouse |
442456 | crouch, jump, use, reload | <kbd>Ctrl</kbd> <kbd>Space</kbd> <kbd>E</kbd> <kbd>R</kbd> | the pad under the view |
@@ -445,7 +459,7 @@ On a touch screen the same actions are a pad, and the view is the aim:
445459 | scoreboard | <kbd>Tab</kbd> | hold `scores` |
446460 | buy | <kbd>B</kbd> | `buy`, and `Close` when you are done |
447461 | radio (go A, go B, hold) | <kbd>Z</kbd> | `radio`, then one of the three buttons |
448 | pause the match, leave it, or change mute/volume/sensitivity | <kbd>Esc</kbd> | `menu` |
462 | pause the match, leave it, or change mute/volume/sensitivity/`Touch sens` | <kbd>Esc</kbd> | `menu` |
449463
450464 Firing is a second finger rather than a button under your aiming thumb because
451465 a touch keeps the target it started on for its whole life: the finger already
src/client/input.test.ts +46
@@ -171,6 +171,52 @@ test("a locked mouse turns less while the scope is up, and the ratio takes effec
171171 expect(controls.takeLook()[0]).toBeCloseTo(hip, 10);
172172 });
173173
174 // --- the thumb's own sensitivity: a touch drag never reads `sensitivity`,
175 // the mouse's number, and changing one never moves the other.
176
177 test("the same drag turns by different degrees at two touch sensitivities", () => {
178 const { canvas, controls } = harness();
179 controls.touchSensitivity = 1;
180 touch(canvas, "touchstart", at(1, 700));
181 touch(canvas, "touchmove", at(1, 740));
182 const slow = controls.takeLook()[0];
183 touch(canvas, "touchend", at(1, 740));
184
185 controls.touchSensitivity = 5;
186 touch(canvas, "touchstart", at(2, 700));
187 touch(canvas, "touchmove", at(2, 740));
188 const fast = controls.takeLook()[0];
189
190 expect(slow).toBeCloseTo(40 * 1 * TOUCH_DEGREES, 10);
191 expect(fast).toBeCloseTo(40 * 5 * TOUCH_DEGREES, 10);
192 expect(fast).toBeGreaterThan(slow);
193 });
194
195 test("changing the touch sensitivity leaves the mouse's alone, and a mouse move ignores it entirely", () => {
196 const { canvas, controls } = harness();
197 const window = globals.window as Canvas;
198 controls.locked = true;
199
200 controls.touchSensitivity = 9;
201 window.fire("mousemove", { movementX: 50, movementY: 0 });
202 const [turned] = controls.takeLook();
203 expect(turned).toBeCloseTo(50 * controls.sensitivity * MOUSE_DEGREES, 10);
204 expect(controls.sensitivity).toBe(2.2); // untouched by the thumb's field
205
206 touch(canvas, "touchstart", at(1, 700));
207 touch(canvas, "touchmove", at(1, 740));
208 expect(controls.takeLook()[0]).toBeCloseTo(40 * 9 * TOUCH_DEGREES, 10);
209 });
210
211 test("createControls takes the thumb's sensitivity as its own argument, separate from the mouse's", () => {
212 const canvas = target({ clientWidth: 1000, clientHeight: 600 });
213 globals.window = target();
214 globals.document = target({ pointerLockElement: null });
215 const controls = createControls(canvas as unknown as HTMLCanvasElement, 2.2, DEFAULT_BINDINGS, DEFAULT_ZOOM_RATIO, 7);
216 expect(controls.touchSensitivity).toBe(7);
217 expect(controls.sensitivity).toBe(2.2);
218 });
219
174220 test("a drag while scoped is scaled too — the pad is not left with the old behaviour", () => {
175221 const { canvas, controls } = harness();
176222 touch(canvas, "touchstart", at(1, 700));
src/client/input.ts +8 −4
@@ -52,6 +52,8 @@ export type Controls = {
5252 /** 1 primary, 2 pistol, 3 knife, 4 grenade — main knows what they are. */
5353 readonly slotWanted: 1 | 2 | 3 | 4 | null;
5454 sensitivity: number;
55 /** A thumb's own sensitivity, apart from the mouse's — touches whose role is `look` only. */
56 touchSensitivity: number;
5557 /** The scope's share of that sensitivity while it is up — see zoom.ts. */
5658 zoomRatio: number;
5759 /**
@@ -118,7 +120,7 @@ function reverseBindings(bindings: Record<Action, string>): Record<string, Actio
118120
119121 export function createControls(
120122 canvas: HTMLCanvasElement, sensitivity = 2.2, bindings: Record<Action, string> = DEFAULT_BINDINGS,
121 zoomRatio = DEFAULT_ZOOM_RATIO,
123 zoomRatio = DEFAULT_ZOOM_RATIO, touchSensitivity = 2.2,
122124 ): Controls {
123125 const press = createPress(LATCHED);
124126 const input: Input = { ...NO_INPUT };
@@ -156,6 +158,7 @@ export function createControls(
156158 : press.isDown("slot2") ? 2 : press.isDown("slot1") ? 1 : null;
157159 },
158160 sensitivity,
161 touchSensitivity,
159162 zoomRatio,
160163 scoped: false,
161164 locked: false,
@@ -271,10 +274,11 @@ export function createControls(
271274 stick.x = clamp((touch.clientX - state.startX) / 60, -1, 1);
272275 stick.y = clamp((state.startY - touch.clientY) / 60, -1, 1);
273276 } else if (state.role === "look") {
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.
277 // The thumb's own sensitivity, not the mouse's: a drag under a scope
278 // is the same problem, and a thumb has less screen to give away than
279 // a mouse does.
276280 const look = (movement: number): number =>
277 lookDelta(movement, controls.sensitivity, controls.scoped, controls.zoomRatio, TOUCH_DEGREES);
281 lookDelta(movement, controls.touchSensitivity, controls.scoped, controls.zoomRatio, TOUCH_DEGREES);
278282 lookX += look(touch.clientX - state.x);
279283 lookY += look(touch.clientY - state.y);
280284 }
src/client/main.ts +27 −3
@@ -73,6 +73,8 @@ function boot(): void {
7373 const discardWarning = $("discard-warning");
7474 const sensField = $<HTMLInputElement>("sens");
7575 const pauseSensField = $<HTMLInputElement>("pause-sens");
76 const touchSensWrap = $("pause-touch-sens-wrap");
77 const touchSensField = $<HTMLInputElement>("pause-touch-sens");
7678 const zoomField = $<HTMLInputElement>("zoom-sens");
7779 const pauseZoomField = $<HTMLInputElement>("pause-zoom-sens");
7880 const volumeField = $<HTMLInputElement>("volume");
@@ -110,7 +112,9 @@ function boot(): void {
110112 // click is already in force once the context exists to hear it.
111113 audio.setVolume(settings.volume);
112114 audio.setMuted(settings.muted);
113 const controls = createControls(scene, settings.sensitivity, settings.bindings, settings.zoomRatio);
115 const controls = createControls(
116 scene, settings.sensitivity, settings.bindings, settings.zoomRatio, settings.touchSensitivity,
117 );
114118 attachButtons(document, controls);
115119
116120 // The menu's sensitivity field and the pause overlay's are the same number:
@@ -125,6 +129,19 @@ function boot(): void {
125129 pauseSensField.value = String(next);
126130 saveSettings(settings);
127131 };
132 // The thumb's own number, reachable only from the pause overlay — there is
133 // no menu twin, because a device without a touch screen never sees the
134 // field to move it. A drag still goes through `controls.touchSensitivity`,
135 // never `controls.sensitivity`, so this can never bleed into the mouse.
136 const syncTouchSensitivity = (value: number): void => {
137 const next = Number.isFinite(value)
138 ? Math.min(SENSITIVITY_MAX, Math.max(SENSITIVITY_MIN, value))
139 : DEFAULT_SETTINGS.touchSensitivity;
140 settings = { ...settings, touchSensitivity: next };
141 controls.touchSensitivity = next;
142 touchSensField.value = String(next);
143 saveSettings(settings);
144 };
128145 // The scope's share of that sensitivity, paired the same way. It reaches the
129146 // mouse and the finger through the same `controls`, so a change lands on the
130147 // very next movement — mid-match, mid-scope.
@@ -181,6 +198,7 @@ function boot(): void {
181198 };
182199 sensField.value = String(settings.sensitivity);
183200 pauseSensField.value = String(settings.sensitivity);
201 touchSensField.value = String(settings.touchSensitivity);
184202 zoomField.value = String(settings.zoomRatio);
185203 pauseZoomField.value = String(settings.zoomRatio);
186204 volumeField.value = String(Math.round(settings.volume * 100));
@@ -198,6 +216,7 @@ function boot(): void {
198216 // is not a value to clamp and snap back on every keystroke.
199217 sensField.addEventListener("change", () => syncSensitivity(Number(sensField.value)));
200218 pauseSensField.addEventListener("change", () => syncSensitivity(Number(pauseSensField.value)));
219 touchSensField.addEventListener("change", () => syncTouchSensitivity(Number(touchSensField.value)));
201220 zoomField.addEventListener("change", () => syncZoomRatio(Number(zoomField.value)));
202221 pauseZoomField.addEventListener("change", () => syncZoomRatio(Number(pauseZoomField.value)));
203222 difficultyField.addEventListener("change", () => syncDifficulty(difficultyField.value));
@@ -220,8 +239,8 @@ function boot(): void {
220239 // The pause dialog sits over a live match: a key typed into one of its
221240 // fields must never reach the window-level handler that drives the game.
222241 for (const field of [
223 pauseSensField, pauseZoomField, volumeField, muteButton, pauseDifficultyField, pauseMotionField,
224 fullscreenPauseButton,
242 pauseSensField, touchSensField, pauseZoomField, volumeField, muteButton, pauseDifficultyField,
243 pauseMotionField, fullscreenPauseButton,
225244 ])
226245 field.addEventListener("keydown", (event) => event.stopPropagation());
227246
@@ -411,6 +430,10 @@ function boot(): void {
411430 const lock = (): void => {
412431 if (!coarse) void scene.requestPointerLock();
413432 };
433 // The thumb's own field exists only where a thumb does — the setting is
434 // real either way, but a mouse-and-keyboard page never grows a control for
435 // a pointer it does not have.
436 show(touchSensWrap, coarse);
414437 /**
415438 * The touch pad is up only while a match is being played and nothing is over
416439 * the view: an overlay is a dialog, and a jump button still live underneath
@@ -685,6 +708,7 @@ function boot(): void {
685708 savedRound = 0;
686709 watching = world.me.id;
687710 controls.sensitivity = settings.sensitivity;
711 controls.touchSensitivity = settings.touchSensitivity;
688712 controls.zoomRatio = settings.zoomRatio;
689713 controls.scoped = false;
690714 running = true;
src/client/settings.test.ts +29 −4
@@ -32,11 +32,13 @@ test("a JSON value that isn't an object is the default", () => {
3232
3333 test("out-of-range numbers are clamped, not rejected", () => {
3434 expect(parseSettings('{"volume":5,"sensitivity":9999,"zoomRatio":4}')).toEqual({
35 muted: false, volume: 1, sensitivity: 12, zoomRatio: 1, difficulty: "normal", map: "de_forge",
35 muted: false, volume: 1, sensitivity: 12, touchSensitivity: DEFAULT_SETTINGS.touchSensitivity,
36 zoomRatio: 1, difficulty: "normal", map: "de_forge",
3637 motion: "full", crosshair: DEFAULT_CROSSHAIR, bindings: DEFAULT_BINDINGS,
3738 });
3839 expect(parseSettings('{"volume":-3,"sensitivity":-1,"zoomRatio":-1}')).toEqual({
39 muted: false, volume: 0, sensitivity: 0.2, zoomRatio: 0.1, difficulty: "normal", map: "de_forge",
40 muted: false, volume: 0, sensitivity: 0.2, touchSensitivity: DEFAULT_SETTINGS.touchSensitivity,
41 zoomRatio: 0.1, difficulty: "normal", map: "de_forge",
4042 motion: "full", crosshair: DEFAULT_CROSSHAIR, bindings: DEFAULT_BINDINGS,
4143 });
4244 });
@@ -56,8 +58,8 @@ test("a whole, valid settings object round-trips", () => {
5658 const bindings = { ...DEFAULT_BINDINGS, forward: "KeyG" };
5759 const crosshair = { colour: "cyan", size: 12, gap: 2, thickness: 3, dot: true, dynamic: false } as const;
5860 const stored: Settings = {
59 muted: true, volume: 0.4, sensitivity: 3.5, zoomRatio: 0.5, difficulty: "hard", map: "de_forge",
60 motion: "reduced", crosshair, bindings,
61 muted: true, volume: 0.4, sensitivity: 3.5, touchSensitivity: 4.1, zoomRatio: 0.5, difficulty: "hard",
62 map: "de_forge", motion: "reduced", crosshair, bindings,
6163 };
6264 expect(parseSettings(JSON.stringify(stored))).toEqual(stored);
6365 });
@@ -74,6 +76,21 @@ test("the zoom ratio round-trips, and a value outside its limits never reaches t
7476 expect(parseSettings('{"muted":true}').zoomRatio).toBe(DEFAULT_ZOOM_RATIO);
7577 });
7678
79 test("the touch sensitivity round-trips, is clamped like the mouse's, and defaults to the mouse's own default", () => {
80 expect(parseSettings(null).touchSensitivity).toBe(DEFAULT_SETTINGS.sensitivity);
81 expect(parseSettings('{"touchSensitivity":5}').touchSensitivity).toBe(5);
82 expect(parseSettings('{"touchSensitivity":9999}').touchSensitivity).toBe(12);
83 expect(parseSettings('{"touchSensitivity":-1}').touchSensitivity).toBe(0.2);
84 for (const raw of ['{"touchSensitivity":"3"}', '{"touchSensitivity":null}', '{"touchSensitivity":"NaN"}'])
85 expect(parseSettings(raw).touchSensitivity).toBe(DEFAULT_SETTINGS.touchSensitivity);
86 });
87
88 test("the mouse's sensitivity and the thumb's move independently", () => {
89 const parsed = parseSettings('{"sensitivity":8,"touchSensitivity":0.5}');
90 expect(parsed.sensitivity).toBe(8);
91 expect(parsed.touchSensitivity).toBe(0.5);
92 });
93
7794 test("a settings blob written before the scope had its own sensitivity still loads", () => {
7895 const old = '{"muted":true,"volume":0.4,"sensitivity":3.5,"difficulty":"hard",' +
7996 `"crosshair":${JSON.stringify(DEFAULT_CROSSHAIR)},"bindings":${JSON.stringify(DEFAULT_BINDINGS)}}`;
@@ -82,6 +99,14 @@ test("a settings blob written before the scope had its own sensitivity still loa
8299 expect(parsed.sensitivity).toBe(3.5);
83100 });
84101
102 test("a settings blob written before the thumb had its own sensitivity still loads", () => {
103 const old = '{"muted":true,"volume":0.4,"sensitivity":3.5,"zoomRatio":0.5,"difficulty":"hard",' +
104 `"crosshair":${JSON.stringify(DEFAULT_CROSSHAIR)},"bindings":${JSON.stringify(DEFAULT_BINDINGS)}}`;
105 const parsed = parseSettings(old);
106 expect(parsed.touchSensitivity).toBe(DEFAULT_SETTINGS.touchSensitivity);
107 expect(parsed.sensitivity).toBe(3.5);
108 });
109
85110 test("a settings blob written before the crosshair existed still loads, with today's crosshair", () => {
86111 // Exactly what `saveSettings` wrote yesterday: no `crosshair` key at all.
87112 const old = '{"muted":true,"volume":0.4,"sensitivity":3.5,"difficulty":"hard",' +
src/client/settings.ts +8 −1
@@ -23,6 +23,8 @@ export type Settings = {
2323 muted: boolean;
2424 volume: number;
2525 sensitivity: number;
26 /** The thumb's own look sensitivity — a touch drag never reads `sensitivity`. */
27 touchSensitivity: number;
2628 /** What the look sensitivity is multiplied by while a scope is up. */
2729 zoomRatio: number;
2830 difficulty: Difficulty;
@@ -34,7 +36,9 @@ export type Settings = {
3436 };
3537
3638 export const DEFAULT_SETTINGS: Settings = {
37 muted: false, volume: 1, sensitivity: 2.2, zoomRatio: DEFAULT_ZOOM_RATIO,
39 // Same number as the mouse's: today's touch feel, before this setting
40 // existed, was `sensitivity` read for a touch too — this is that feel, kept.
41 muted: false, volume: 1, sensitivity: 2.2, touchSensitivity: 2.2, zoomRatio: DEFAULT_ZOOM_RATIO,
3842 difficulty: DEFAULT_DIFFICULTY, map: DEFAULT_MAP, motion: DEFAULT_MOTION,
3943 crosshair: DEFAULT_CROSSHAIR,
4044 bindings: DEFAULT_BINDINGS,
@@ -145,6 +149,9 @@ export function parseSettings(raw: string | null, unchosen: Motion = DEFAULT_MOT
145149 muted: obj.muted === true,
146150 volume: bounded(obj.volume, 0, 1, DEFAULT_SETTINGS.volume),
147151 sensitivity: bounded(obj.sensitivity, SENSITIVITY_MIN, SENSITIVITY_MAX, DEFAULT_SETTINGS.sensitivity),
152 touchSensitivity: bounded(
153 obj.touchSensitivity, SENSITIVITY_MIN, SENSITIVITY_MAX, DEFAULT_SETTINGS.touchSensitivity,
154 ),
148155 zoomRatio: bounded(obj.zoomRatio, ZOOM_RATIO_MIN, ZOOM_RATIO_MAX, DEFAULT_SETTINGS.zoomRatio),
149156 difficulty: isDifficulty(obj.difficulty) ? obj.difficulty : DEFAULT_SETTINGS.difficulty,
150157 map: isMapName(obj.map) ? obj.map : DEFAULT_SETTINGS.map,
src/pages/game.ts +6 −1
@@ -354,7 +354,10 @@ export function gamePage(db: Database, options: PageOptions): string {
354354 back to the plan it rolled. The other side never hears you.</p>
355355 <p class="hint">On a phone: the left of the view is a thumb stick, the right aims, a second
356356 finger on the right fires, and the pad under the view does the rest — guns, the grenade,
357 scores, buy, radio and <code>menu</code>, which pauses the match and is where you leave it from.</p>
357 scores, buy, radio and <code>menu</code>, which pauses the match and is where you leave it from.
358 <b>Touch sens</b> is the thumb's own aiming speed, separate from <b>Sensitivity</b> above which
359 only the mouse feels — it lives in that pause overlay, the only place a phone can reach
360 settings mid-match.</p>
358361 <p class="hint">Paused means paused: the world stops while the overlay is up, and so does
359362 tabbing away — the match is waiting for you when you come back.</p>
360363 <details class="controls"><summary>Controls</summary><div id="controls-menu"></div></details>
@@ -369,6 +372,8 @@ export function gamePage(db: Database, options: PageOptions): string {
369372 while it comes back; nothing is lost, and Resume lights up when the view does.</p>
370373 <div class="row">
371374 <label>Sensitivity<input id="pause-sens" type="number" min="0.2" max="12" step="0.1"></label>
375 <label id="pause-touch-sens-wrap" hidden>Touch sens<input id="pause-touch-sens" type="number"
376 min="0.2" max="12" step="0.1"><small>the thumb's own</small></label>
372377 ${zoomControl("pause-zoom-sens")}
373378 <label>Volume<input id="volume" type="range" min="0" max="100" step="1"></label>
374379 <label>Difficulty<select id="pause-difficulty">

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs