fuzzyworld

bubbles / files / src / input / TiltInput.ts

TiltInput.ts

Blame: the commit that last changed each line · file · history

dcc4757f 2026-04-09 Pavel Safronov1import { Vector2 } from '../physics/Vector2.js';
2
3type OrientationPermission = 'granted' | 'denied' | 'pending';
4
5/**
6 * Reads device tilt and exposes it as a normalised gravity Vector2
7 * (px/s² scale, ~980 = 1 g). Falls back to mouse position on desktop.
8 */
9export class TiltInput {
10 private _gravity: Vector2 = new Vector2(0, 980);
11 private _permission: OrientationPermission = 'pending';
12 private _usingMouse = false;
13
14 constructor(private readonly canvas: HTMLCanvasElement) {
15 this.tryAttachOrientation();
16 this.attachMouseFallback();
17 }
18
19 get gravity(): Vector2 {
20 return this._gravity.clone();
21 }
22
23 /**
24 * Call this from a user-gesture handler (e.g. first touch) to trigger
25 * the iOS DeviceOrientationEvent permission dialog.
26 */
27 async requestPermission(): Promise<void> {
28 // eslint-disable-next-line @typescript-eslint/no-explicit-any
29 const DevOrEvent = (DeviceOrientationEvent as any);
30 if (typeof DevOrEvent.requestPermission === 'function') {
31 try {
32 const result: string = await DevOrEvent.requestPermission();
33 if (result === 'granted') {
34 this._permission = 'granted';
35 this._usingMouse = false;
36 window.addEventListener('deviceorientation', this.handleOrientation);
37 } else {
38 this._permission = 'denied';
39 }
40 } catch {
41 this._permission = 'denied';
42 }
43 }
44 }
45
46 private tryAttachOrientation(): void {
47 // eslint-disable-next-line @typescript-eslint/no-explicit-any
48 const DevOrEvent = (DeviceOrientationEvent as any);
49 if (typeof DevOrEvent.requestPermission === 'function') {
50 // iOS 13+ — need explicit permission, will be requested on first touch
51 this._usingMouse = true;
52 } else if ('DeviceOrientationEvent' in window) {
53 window.addEventListener('deviceorientation', this.handleOrientation);
54 this._permission = 'granted';
55 } else {
56 this._usingMouse = true;
57 }
58 }
59
60 private readonly handleOrientation = (e: DeviceOrientationEvent): void => {
775e32f9 2026-04-09 Pavel Safronov61 if (e.gamma === null || e.beta === null) return;
62 // Full 360° gravity using both gamma (left/right tilt, −90…+90) and beta
63 // (front/back tilt, −180…+180) projected onto the screen plane:
64 //
65 // gx = sin(gamma) * 980
66 // gy = sin(beta) * cos(gamma) * 980
67 //
68 // Upright portrait (beta≈90, gamma=0): gx=0, gy=+980 (normal down)
69 // Upside-down (beta≈−90, gamma=0): gx=0, gy=−980 (gravity up)
70 // Tilted right 30° (beta≈90, gamma=30): gx≈490, gy≈849
71 // Flat face-up (beta=0, gamma=0): gx=0, gy=0
72 const gammaRad = (e.gamma * Math.PI) / 180;
73 const betaRad = (e.beta * Math.PI) / 180;
dcc4757f 2026-04-09 Pavel Safronov74
75 this._gravity = new Vector2(
76 Math.sin(gammaRad) * 980,
775e32f9 2026-04-09 Pavel Safronov77 Math.sin(betaRad) * Math.cos(gammaRad) * 980,
dcc4757f 2026-04-09 Pavel Safronov78 );
79 };
80
81 private attachMouseFallback(): void {
82 window.addEventListener('mousemove', (e: MouseEvent) => {
83 if (!this._usingMouse) return;
84 const rect = this.canvas.getBoundingClientRect();
85 const cx = rect.left + rect.width / 2;
86 const cy = rect.top + rect.height / 2;
87 const dx = (e.clientX - cx) / (rect.width / 2); // -1…+1
88 const dy = (e.clientY - cy) / (rect.height / 2); // -1…+1
89 const tiltX = Math.max(-1, Math.min(1, dx)) * 45 * (Math.PI / 180);
90 this._gravity = new Vector2(
91 Math.sin(tiltX) * 980,
92 Math.cos(tiltX) * 980 * (1 - Math.abs(dy) * 0.2),
93 );
94 });
95 }
96
97 destroy(): void {
98 window.removeEventListener('deviceorientation', this.handleOrientation);
99 }
100}