@aura3d/input 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/README.md +17 -0
  2. package/dist/ActionMap.d.ts +44 -0
  3. package/dist/ActionMap.d.ts.map +1 -0
  4. package/dist/ActionMap.js +74 -0
  5. package/dist/ActionMap.js.map +1 -0
  6. package/dist/GamepadDevice.d.ts +17 -0
  7. package/dist/GamepadDevice.d.ts.map +1 -0
  8. package/dist/GamepadDevice.js +27 -0
  9. package/dist/GamepadDevice.js.map +1 -0
  10. package/dist/GamepadInput.d.ts +3 -0
  11. package/dist/GamepadInput.d.ts.map +1 -0
  12. package/dist/GamepadInput.js +2 -0
  13. package/dist/GamepadInput.js.map +1 -0
  14. package/dist/GestureControls.d.ts +3 -0
  15. package/dist/GestureControls.d.ts.map +1 -0
  16. package/dist/GestureControls.js +2 -0
  17. package/dist/GestureControls.js.map +1 -0
  18. package/dist/GestureHapticsFixtures.d.ts +57 -0
  19. package/dist/GestureHapticsFixtures.d.ts.map +1 -0
  20. package/dist/GestureHapticsFixtures.js +115 -0
  21. package/dist/GestureHapticsFixtures.js.map +1 -0
  22. package/dist/GestureRecognizer.d.ts +19 -0
  23. package/dist/GestureRecognizer.d.ts.map +1 -0
  24. package/dist/GestureRecognizer.js +32 -0
  25. package/dist/GestureRecognizer.js.map +1 -0
  26. package/dist/InputActionBindingFixtures.d.ts +44 -0
  27. package/dist/InputActionBindingFixtures.d.ts.map +1 -0
  28. package/dist/InputActionBindingFixtures.js +86 -0
  29. package/dist/InputActionBindingFixtures.js.map +1 -0
  30. package/dist/InputReplay.d.ts +97 -0
  31. package/dist/InputReplay.d.ts.map +1 -0
  32. package/dist/InputReplay.js +189 -0
  33. package/dist/InputReplay.js.map +1 -0
  34. package/dist/InputSnapshot.d.ts +44 -0
  35. package/dist/InputSnapshot.d.ts.map +1 -0
  36. package/dist/InputSnapshot.js +44 -0
  37. package/dist/InputSnapshot.js.map +1 -0
  38. package/dist/InputSystem.d.ts +24 -0
  39. package/dist/InputSystem.d.ts.map +1 -0
  40. package/dist/InputSystem.js +77 -0
  41. package/dist/InputSystem.js.map +1 -0
  42. package/dist/InteractionSystem.d.ts +42 -0
  43. package/dist/InteractionSystem.d.ts.map +1 -0
  44. package/dist/InteractionSystem.js +80 -0
  45. package/dist/InteractionSystem.js.map +1 -0
  46. package/dist/KeyboardDevice.d.ts +18 -0
  47. package/dist/KeyboardDevice.d.ts.map +1 -0
  48. package/dist/KeyboardDevice.js +22 -0
  49. package/dist/KeyboardDevice.js.map +1 -0
  50. package/dist/PickingRay.d.ts +10 -0
  51. package/dist/PickingRay.d.ts.map +1 -0
  52. package/dist/PickingRay.js +27 -0
  53. package/dist/PickingRay.js.map +1 -0
  54. package/dist/PointerDevice.d.ts +51 -0
  55. package/dist/PointerDevice.d.ts.map +1 -0
  56. package/dist/PointerDevice.js +75 -0
  57. package/dist/PointerDevice.js.map +1 -0
  58. package/dist/VirtualTouchControls.d.ts +57 -0
  59. package/dist/VirtualTouchControls.d.ts.map +1 -0
  60. package/dist/VirtualTouchControls.js +127 -0
  61. package/dist/VirtualTouchControls.js.map +1 -0
  62. package/dist/WebXRSessionController.d.ts +108 -0
  63. package/dist/WebXRSessionController.d.ts.map +1 -0
  64. package/dist/WebXRSessionController.js +143 -0
  65. package/dist/WebXRSessionController.js.map +1 -0
  66. package/dist/XRFixtures.d.ts +65 -0
  67. package/dist/XRFixtures.d.ts.map +1 -0
  68. package/dist/XRFixtures.js +136 -0
  69. package/dist/XRFixtures.js.map +1 -0
  70. package/dist/controls/CameraRig.d.ts +12 -0
  71. package/dist/controls/CameraRig.d.ts.map +1 -0
  72. package/dist/controls/CameraRig.js +32 -0
  73. package/dist/controls/CameraRig.js.map +1 -0
  74. package/dist/controls/ControlTypes.d.ts +17 -0
  75. package/dist/controls/ControlTypes.d.ts.map +1 -0
  76. package/dist/controls/ControlTypes.js +4 -0
  77. package/dist/controls/ControlTypes.js.map +1 -0
  78. package/dist/controls/EditorFlyControls.d.ts +20 -0
  79. package/dist/controls/EditorFlyControls.d.ts.map +1 -0
  80. package/dist/controls/EditorFlyControls.js +44 -0
  81. package/dist/controls/EditorFlyControls.js.map +1 -0
  82. package/dist/controls/FirstPersonControls.d.ts +22 -0
  83. package/dist/controls/FirstPersonControls.d.ts.map +1 -0
  84. package/dist/controls/FirstPersonControls.js +44 -0
  85. package/dist/controls/FirstPersonControls.js.map +1 -0
  86. package/dist/controls/OrbitControls.d.ts +49 -0
  87. package/dist/controls/OrbitControls.d.ts.map +1 -0
  88. package/dist/controls/OrbitControls.js +131 -0
  89. package/dist/controls/OrbitControls.js.map +1 -0
  90. package/dist/controls/PointerLockControls.d.ts +11 -0
  91. package/dist/controls/PointerLockControls.d.ts.map +1 -0
  92. package/dist/controls/PointerLockControls.js +19 -0
  93. package/dist/controls/PointerLockControls.js.map +1 -0
  94. package/dist/controls/SceneCameraAdapter.d.ts +10 -0
  95. package/dist/controls/SceneCameraAdapter.d.ts.map +1 -0
  96. package/dist/controls/SceneCameraAdapter.js +90 -0
  97. package/dist/controls/SceneCameraAdapter.js.map +1 -0
  98. package/dist/controls/ThirdPersonFollowControls.d.ts +20 -0
  99. package/dist/controls/ThirdPersonFollowControls.d.ts.map +1 -0
  100. package/dist/controls/ThirdPersonFollowControls.js +33 -0
  101. package/dist/controls/ThirdPersonFollowControls.js.map +1 -0
  102. package/dist/index.d.ts +47 -0
  103. package/dist/index.d.ts.map +1 -0
  104. package/dist/index.js +25 -0
  105. package/dist/index.js.map +1 -0
  106. package/package.json +17 -0
  107. package/src/ActionMap.ts +94 -0
  108. package/src/GamepadDevice.ts +48 -0
  109. package/src/GamepadInput.ts +2 -0
  110. package/src/GestureControls.ts +2 -0
  111. package/src/GestureHapticsFixtures.ts +179 -0
  112. package/src/GestureRecognizer.ts +43 -0
  113. package/src/InputActionBindingFixtures.ts +121 -0
  114. package/src/InputReplay.ts +275 -0
  115. package/src/InputSnapshot.ts +92 -0
  116. package/src/InputSystem.ts +92 -0
  117. package/src/InteractionSystem.ts +119 -0
  118. package/src/KeyboardDevice.ts +32 -0
  119. package/src/PickingRay.ts +49 -0
  120. package/src/PointerDevice.ts +102 -0
  121. package/src/VirtualTouchControls.ts +169 -0
  122. package/src/WebXRSessionController.ts +248 -0
  123. package/src/XRFixtures.ts +203 -0
  124. package/src/controls/CameraRig.ts +38 -0
  125. package/src/controls/ControlTypes.ts +21 -0
  126. package/src/controls/EditorFlyControls.ts +55 -0
  127. package/src/controls/FirstPersonControls.ts +60 -0
  128. package/src/controls/OrbitControls.ts +168 -0
  129. package/src/controls/PointerLockControls.ts +24 -0
  130. package/src/controls/SceneCameraAdapter.ts +100 -0
  131. package/src/controls/ThirdPersonFollowControls.ts +42 -0
  132. package/src/index.ts +72 -0
  133. package/tests/input.test.ts +69 -0
  134. package/tsconfig.json +7 -0
@@ -0,0 +1,92 @@
1
+ export type ButtonState = Readonly<{
2
+ down: boolean;
3
+ pressed: boolean;
4
+ released: boolean;
5
+ }>;
6
+
7
+ export interface PointerSnapshot {
8
+ readonly x: number;
9
+ readonly y: number;
10
+ readonly deltaX: number;
11
+ readonly deltaY: number;
12
+ readonly wheelX: number;
13
+ readonly wheelY: number;
14
+ readonly buttons: ReadonlyMap<number, ButtonState>;
15
+ readonly touches: readonly PointerTouch[];
16
+ }
17
+
18
+ export interface PointerTouch {
19
+ readonly id: number;
20
+ readonly x: number;
21
+ readonly y: number;
22
+ }
23
+
24
+ export interface GamepadSnapshot {
25
+ readonly id: string;
26
+ readonly index: number;
27
+ readonly axes: readonly number[];
28
+ readonly buttons: readonly ButtonState[];
29
+ }
30
+
31
+ export interface InputSnapshotOptions {
32
+ readonly keys?: ReadonlySet<string>;
33
+ readonly previousKeys?: ReadonlySet<string>;
34
+ readonly pointer?: Partial<PointerSnapshot>;
35
+ readonly previousPointerButtons?: ReadonlySet<number>;
36
+ readonly gamepads?: readonly GamepadSnapshot[];
37
+ }
38
+
39
+ const EMPTY_SET = new Set<string>();
40
+ const EMPTY_BUTTON_SET = new Set<number>();
41
+
42
+ export class InputSnapshot {
43
+ readonly keys: ReadonlySet<string>;
44
+ readonly pointer: PointerSnapshot;
45
+ readonly gamepads: readonly GamepadSnapshot[];
46
+
47
+ private readonly previousKeys: ReadonlySet<string>;
48
+
49
+ constructor(options: InputSnapshotOptions = {}) {
50
+ this.keys = new Set(options.keys ?? EMPTY_SET);
51
+ this.previousKeys = new Set(options.previousKeys ?? EMPTY_SET);
52
+ this.gamepads = Object.freeze([...(options.gamepads ?? [])]);
53
+
54
+ const previousButtons = options.previousPointerButtons ?? EMPTY_BUTTON_SET;
55
+ const currentButtons = new Set(options.pointer?.buttons?.keys() ?? []);
56
+ const buttons = new Map<number, ButtonState>();
57
+
58
+ for (const button of new Set([...previousButtons, ...currentButtons])) {
59
+ const down = currentButtons.has(button);
60
+ const wasDown = previousButtons.has(button);
61
+ buttons.set(button, Object.freeze({ down, pressed: down && !wasDown, released: !down && wasDown }));
62
+ }
63
+
64
+ this.pointer = Object.freeze({
65
+ x: options.pointer?.x ?? 0,
66
+ y: options.pointer?.y ?? 0,
67
+ deltaX: options.pointer?.deltaX ?? 0,
68
+ deltaY: options.pointer?.deltaY ?? 0,
69
+ wheelX: options.pointer?.wheelX ?? 0,
70
+ wheelY: options.pointer?.wheelY ?? 0,
71
+ buttons,
72
+ touches: Object.freeze([...(options.pointer?.touches ?? [])])
73
+ });
74
+ }
75
+
76
+ key(code: string): ButtonState {
77
+ const down = this.keys.has(code);
78
+ const wasDown = this.previousKeys.has(code);
79
+ return Object.freeze({ down, pressed: down && !wasDown, released: !down && wasDown });
80
+ }
81
+
82
+ button(button: number): ButtonState {
83
+ return this.pointer.buttons.get(button) ?? Object.freeze({ down: false, pressed: false, released: false });
84
+ }
85
+
86
+ gamepadButton(gamepadIndex: number, buttonIndex: number): ButtonState {
87
+ return (
88
+ this.gamepads.find((gamepad) => gamepad.index === gamepadIndex)?.buttons[buttonIndex] ??
89
+ Object.freeze({ down: false, pressed: false, released: false })
90
+ );
91
+ }
92
+ }
@@ -0,0 +1,92 @@
1
+ import { ActionMap } from "./ActionMap";
2
+ import { GamepadDevice, type GamepadLike } from "./GamepadDevice";
3
+ import { InputSnapshot } from "./InputSnapshot";
4
+ import { KeyboardDevice, type KeyboardEventLike } from "./KeyboardDevice";
5
+ import { PointerDevice, type PointerEventLike, type WheelEventLike } from "./PointerDevice";
6
+
7
+ export interface InputEventTargetLike {
8
+ addEventListener(type: string, listener: EventListener): void;
9
+ removeEventListener(type: string, listener: EventListener): void;
10
+ }
11
+
12
+ export class InputSystem {
13
+ readonly keyboard = new KeyboardDevice();
14
+ readonly pointer = new PointerDevice();
15
+ readonly gamepads = new GamepadDevice();
16
+ readonly actions = new ActionMap();
17
+
18
+ private readonly attachedTargets = new Map<InputEventTargetLike, Array<() => void>>();
19
+ private snapshotRef = new InputSnapshot();
20
+
21
+ constructor(target?: InputEventTargetLike) {
22
+ if (target) {
23
+ this.attach(target);
24
+ }
25
+ }
26
+
27
+ get snapshot(): InputSnapshot {
28
+ return this.snapshotRef;
29
+ }
30
+
31
+ attach(target: InputEventTargetLike): void {
32
+ if (this.attachedTargets.has(target)) {
33
+ return;
34
+ }
35
+
36
+ const onKeyDown = ((event: Event) => this.keyboard.keyDown(event as unknown as KeyboardEventLike)) as EventListener;
37
+ const onKeyUp = ((event: Event) => this.keyboard.keyUp(event as unknown as KeyboardEventLike)) as EventListener;
38
+ const onPointerMove = ((event: Event) => this.pointer.move(event as unknown as PointerEventLike)) as EventListener;
39
+ const onPointerDown = ((event: Event) => this.pointer.down(event as unknown as PointerEventLike)) as EventListener;
40
+ const onPointerUp = ((event: Event) => this.pointer.up(event as unknown as PointerEventLike)) as EventListener;
41
+ const onWheel = ((event: Event) => this.pointer.wheel(event as unknown as WheelEventLike)) as EventListener;
42
+ const onBlur = (() => {
43
+ this.keyboard.blur();
44
+ this.pointer.blur();
45
+ }) as EventListener;
46
+
47
+ const listeners: Array<readonly [string, EventListener]> = [
48
+ ["keydown", onKeyDown],
49
+ ["keyup", onKeyUp],
50
+ ["pointermove", onPointerMove],
51
+ ["pointerdown", onPointerDown],
52
+ ["pointerup", onPointerUp],
53
+ ["wheel", onWheel],
54
+ ["blur", onBlur]
55
+ ];
56
+
57
+ const disposers: Array<() => void> = [];
58
+ for (const [type, listener] of listeners) {
59
+ target.addEventListener(type, listener);
60
+ disposers.push(() => target.removeEventListener(type, listener));
61
+ }
62
+ this.attachedTargets.set(target, disposers);
63
+ }
64
+
65
+ update(gamepads: readonly (GamepadLike | null | undefined)[] = []): InputSnapshot {
66
+ const keyboard = this.keyboard.snapshotSets();
67
+ const pointer = this.pointer.snapshotData();
68
+ this.snapshotRef = new InputSnapshot({
69
+ ...keyboard,
70
+ ...pointer,
71
+ gamepads: this.gamepads.poll(gamepads)
72
+ });
73
+ this.actions.useSnapshot(this.snapshotRef);
74
+ return this.snapshotRef;
75
+ }
76
+
77
+ endFrame(): void {
78
+ this.keyboard.endFrame();
79
+ this.pointer.endFrame();
80
+ }
81
+
82
+ dispose(): void {
83
+ for (const disposers of this.attachedTargets.values()) {
84
+ while (disposers.length > 0) {
85
+ disposers.pop()?.();
86
+ }
87
+ }
88
+ this.attachedTargets.clear();
89
+ this.keyboard.blur();
90
+ this.pointer.blur();
91
+ }
92
+ }
@@ -0,0 +1,119 @@
1
+ import type { Ray } from "@aura3d/math";
2
+ import type { SceneNode } from "@aura3d/scene";
3
+ import type { InputSnapshot } from "./InputSnapshot";
4
+
5
+ type Tuple3 = readonly [number, number, number];
6
+
7
+ export interface InteractionBounds {
8
+ readonly min: Tuple3;
9
+ readonly max: Tuple3;
10
+ }
11
+
12
+ export interface InteractionTarget {
13
+ readonly id: string;
14
+ readonly node?: SceneNode;
15
+ readonly bounds: InteractionBounds;
16
+ }
17
+
18
+ export interface InteractionHit {
19
+ readonly target: InteractionTarget;
20
+ readonly distance: number;
21
+ }
22
+
23
+ export type InteractionEventType = "hover-enter" | "hover-exit" | "pointer-down" | "drag-start" | "drag" | "drag-end" | "click";
24
+
25
+ export interface InteractionEvent {
26
+ readonly type: InteractionEventType;
27
+ readonly hit?: InteractionHit;
28
+ readonly target?: InteractionTarget;
29
+ readonly snapshot: InputSnapshot;
30
+ }
31
+
32
+ export type InteractionListener = (event: InteractionEvent) => void;
33
+ export type InteractionRayProvider = (snapshot: InputSnapshot) => Ray;
34
+ export type InteractionTargetProvider = () => readonly InteractionTarget[];
35
+
36
+ export class InteractionSystem {
37
+ private readonly listeners = new Set<InteractionListener>();
38
+ private hovered?: InteractionTarget;
39
+ private pressed?: InteractionTarget;
40
+ private dragging = false;
41
+
42
+ constructor(
43
+ private readonly rayProvider: InteractionRayProvider,
44
+ private readonly targetProvider: InteractionTargetProvider
45
+ ) {}
46
+
47
+ subscribe(listener: InteractionListener): () => void {
48
+ this.listeners.add(listener);
49
+ return () => this.listeners.delete(listener);
50
+ }
51
+
52
+ update(snapshot: InputSnapshot): InteractionHit | undefined {
53
+ const hit = this.pick(this.rayProvider(snapshot));
54
+ const target = hit?.target;
55
+
56
+ if (this.hovered?.id !== target?.id) {
57
+ if (this.hovered) this.emit({ type: "hover-exit", target: this.hovered, snapshot });
58
+ if (target) this.emit({ type: "hover-enter", hit, target, snapshot });
59
+ this.hovered = target;
60
+ }
61
+
62
+ if (snapshot.button(0).pressed && target) {
63
+ this.pressed = target;
64
+ this.dragging = false;
65
+ this.emit({ type: "pointer-down", hit, target, snapshot });
66
+ }
67
+
68
+ if (this.pressed && snapshot.button(0).down && (snapshot.pointer.deltaX !== 0 || snapshot.pointer.deltaY !== 0)) {
69
+ this.emit({ type: this.dragging ? "drag" : "drag-start", hit, target: this.pressed, snapshot });
70
+ this.dragging = true;
71
+ }
72
+
73
+ if (this.pressed && snapshot.button(0).released) {
74
+ this.emit({ type: this.dragging ? "drag-end" : "click", hit, target: this.pressed, snapshot });
75
+ this.pressed = undefined;
76
+ this.dragging = false;
77
+ }
78
+
79
+ return hit;
80
+ }
81
+
82
+ pick(ray: Ray): InteractionHit | undefined {
83
+ const hits = this.targetProvider()
84
+ .map((target) => ({ target, distance: intersectBounds(ray, target.bounds) }))
85
+ .filter((hit): hit is InteractionHit => hit.distance !== undefined)
86
+ .sort((a, b) => a.distance - b.distance);
87
+ return hits[0];
88
+ }
89
+
90
+ private emit(event: InteractionEvent): void {
91
+ for (const listener of [...this.listeners]) listener(event);
92
+ }
93
+ }
94
+
95
+ function intersectBounds(ray: Ray, bounds: InteractionBounds): number | undefined {
96
+ let tmin = Number.NEGATIVE_INFINITY;
97
+ let tmax = Number.POSITIVE_INFINITY;
98
+
99
+ for (const axis of [0, 1, 2] as const) {
100
+ const origin = axis === 0 ? ray.origin.x : axis === 1 ? ray.origin.y : ray.origin.z;
101
+ const direction = axis === 0 ? ray.direction.x : axis === 1 ? ray.direction.y : ray.direction.z;
102
+ const min = bounds.min[axis];
103
+ const max = bounds.max[axis];
104
+ if (Math.abs(direction) < 1e-8) {
105
+ if (origin < min || origin > max) return undefined;
106
+ continue;
107
+ }
108
+ const invD = 1 / direction;
109
+ let near = (min - origin) * invD;
110
+ let far = (max - origin) * invD;
111
+ if (near > far) [near, far] = [far, near];
112
+ tmin = Math.max(tmin, near);
113
+ tmax = Math.min(tmax, far);
114
+ if (tmin > tmax) return undefined;
115
+ }
116
+
117
+ const distance = tmin >= 0 ? tmin : tmax;
118
+ return distance >= 0 ? distance : undefined;
119
+ }
@@ -0,0 +1,32 @@
1
+ export interface KeyboardEventLike {
2
+ readonly code: string;
3
+ readonly repeat?: boolean;
4
+ preventDefault?(): void;
5
+ }
6
+
7
+ export class KeyboardDevice {
8
+ private current = new Set<string>();
9
+ private previous = new Set<string>();
10
+
11
+ keyDown(event: KeyboardEventLike): void {
12
+ if (!event.repeat) {
13
+ this.current.add(event.code);
14
+ }
15
+ }
16
+
17
+ keyUp(event: KeyboardEventLike): void {
18
+ this.current.delete(event.code);
19
+ }
20
+
21
+ blur(): void {
22
+ this.current.clear();
23
+ }
24
+
25
+ snapshotSets(): { readonly keys: ReadonlySet<string>; readonly previousKeys: ReadonlySet<string> } {
26
+ return { keys: new Set(this.current), previousKeys: new Set(this.previous) };
27
+ }
28
+
29
+ endFrame(): void {
30
+ this.previous = new Set(this.current);
31
+ }
32
+ }
@@ -0,0 +1,49 @@
1
+ import { Ray, Vector3 } from "@aura3d/math";
2
+ import { invertMat4, type Camera } from "@aura3d/scene";
3
+
4
+ type Tuple3 = readonly [number, number, number];
5
+ type TupleMat4 = readonly [
6
+ number, number, number, number,
7
+ number, number, number, number,
8
+ number, number, number, number,
9
+ number, number, number, number
10
+ ];
11
+
12
+ export interface PickingRayViewport {
13
+ readonly x: number;
14
+ readonly y: number;
15
+ readonly width: number;
16
+ readonly height: number;
17
+ }
18
+
19
+ export function pickingRayFromCamera(
20
+ camera: Camera,
21
+ screenX: number,
22
+ screenY: number,
23
+ viewport: PickingRayViewport = camera.viewport
24
+ ): Ray {
25
+ camera.updateCameraMatrices();
26
+ const ndcX = ((screenX - viewport.x) / viewport.width) * 2 - 1;
27
+ const ndcY = 1 - ((screenY - viewport.y) / viewport.height) * 2;
28
+ const inverseViewProjection = invertMat4(camera.viewProjectionMatrix) as TupleMat4;
29
+ const near = transformClip(inverseViewProjection, [ndcX, ndcY, -1]);
30
+ const far = transformClip(inverseViewProjection, [ndcX, ndcY, 1]);
31
+ return new Ray(toVector3(near), toVector3(far).subtract(toVector3(near)));
32
+ }
33
+
34
+ function transformClip(matrix: TupleMat4, point: Tuple3): Tuple3 {
35
+ const x = point[0];
36
+ const y = point[1];
37
+ const z = point[2];
38
+ const w = matrix[3] * x + matrix[7] * y + matrix[11] * z + matrix[15];
39
+ const invW = Math.abs(w) <= 1e-8 ? 1 : 1 / w;
40
+ return [
41
+ (matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12]) * invW,
42
+ (matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13]) * invW,
43
+ (matrix[2] * x + matrix[6] * y + matrix[10] * z + matrix[14]) * invW
44
+ ];
45
+ }
46
+
47
+ function toVector3(value: Tuple3): Vector3 {
48
+ return new Vector3(value[0], value[1], value[2]);
49
+ }
@@ -0,0 +1,102 @@
1
+ import type { PointerTouch } from "./InputSnapshot";
2
+
3
+ export interface PointerEventLike {
4
+ readonly clientX: number;
5
+ readonly clientY: number;
6
+ readonly button?: number;
7
+ readonly buttons?: number;
8
+ readonly pointerId?: number;
9
+ preventDefault?(): void;
10
+ }
11
+
12
+ export interface WheelEventLike {
13
+ readonly deltaX: number;
14
+ readonly deltaY: number;
15
+ preventDefault?(): void;
16
+ }
17
+
18
+ export class PointerDevice {
19
+ x = 0;
20
+ y = 0;
21
+ deltaX = 0;
22
+ deltaY = 0;
23
+ wheelX = 0;
24
+ wheelY = 0;
25
+
26
+ private readonly buttons = new Set<number>();
27
+ private previousButtons = new Set<number>();
28
+ private readonly touches = new Map<number, PointerTouch>();
29
+ private pixelRatio = 1;
30
+
31
+ setDevicePixelRatio(value: number): void {
32
+ if (!Number.isFinite(value) || value <= 0) {
33
+ throw new Error("Device pixel ratio must be positive");
34
+ }
35
+ this.pixelRatio = value;
36
+ }
37
+
38
+ move(event: PointerEventLike): void {
39
+ const nextX = event.clientX * this.pixelRatio;
40
+ const nextY = event.clientY * this.pixelRatio;
41
+ this.deltaX += nextX - this.x;
42
+ this.deltaY += nextY - this.y;
43
+ this.x = nextX;
44
+ this.y = nextY;
45
+
46
+ if (event.pointerId !== undefined) {
47
+ this.touches.set(event.pointerId, { id: event.pointerId, x: this.x, y: this.y });
48
+ }
49
+ }
50
+
51
+ down(event: PointerEventLike): void {
52
+ this.move(event);
53
+ this.buttons.add(event.button ?? 0);
54
+ }
55
+
56
+ up(event: PointerEventLike): void {
57
+ this.move(event);
58
+ this.buttons.delete(event.button ?? 0);
59
+ if (event.pointerId !== undefined) {
60
+ this.touches.delete(event.pointerId);
61
+ }
62
+ }
63
+
64
+ wheel(event: WheelEventLike): void {
65
+ this.wheelX += event.deltaX;
66
+ this.wheelY += event.deltaY;
67
+ }
68
+
69
+ blur(): void {
70
+ this.buttons.clear();
71
+ this.touches.clear();
72
+ }
73
+
74
+ snapshotData() {
75
+ const buttonMap = new Map<number, { down: boolean; pressed: boolean; released: boolean }>();
76
+ for (const button of this.buttons) {
77
+ buttonMap.set(button, { down: true, pressed: false, released: false });
78
+ }
79
+
80
+ return {
81
+ pointer: {
82
+ x: this.x,
83
+ y: this.y,
84
+ deltaX: this.deltaX,
85
+ deltaY: this.deltaY,
86
+ wheelX: this.wheelX,
87
+ wheelY: this.wheelY,
88
+ buttons: buttonMap,
89
+ touches: [...this.touches.values()]
90
+ },
91
+ previousPointerButtons: new Set(this.previousButtons)
92
+ };
93
+ }
94
+
95
+ endFrame(): void {
96
+ this.previousButtons = new Set(this.buttons);
97
+ this.deltaX = 0;
98
+ this.deltaY = 0;
99
+ this.wheelX = 0;
100
+ this.wheelY = 0;
101
+ }
102
+ }
@@ -0,0 +1,169 @@
1
+ export interface VirtualJoystickConfig {
2
+ readonly center: readonly [number, number];
3
+ readonly radius: number;
4
+ readonly deadZone?: number;
5
+ readonly maxDistance?: number;
6
+ readonly fixed?: boolean;
7
+ readonly returnToCenter?: boolean;
8
+ }
9
+
10
+ export interface VirtualTouchPoint {
11
+ readonly id: number;
12
+ readonly x: number;
13
+ readonly y: number;
14
+ }
15
+
16
+ export interface VirtualTouchJoystickSnapshot {
17
+ readonly source: "origin-master-touch-joystick-virtual-input-adapted";
18
+ readonly active: boolean;
19
+ readonly activeTouchId: number | null;
20
+ readonly center: readonly [number, number];
21
+ readonly stick: readonly [number, number];
22
+ readonly value: readonly [number, number];
23
+ readonly magnitude: number;
24
+ readonly deadZone: number;
25
+ readonly consumedTouches: number;
26
+ readonly evidence: {
27
+ readonly oldCodebasePort: true;
28
+ readonly virtualJoystick: boolean;
29
+ readonly deadZone: boolean;
30
+ readonly clampedMagnitude: boolean;
31
+ readonly returnToCenter: boolean;
32
+ readonly floatingCenter: boolean;
33
+ };
34
+ }
35
+
36
+ export class VirtualTouchJoystick {
37
+ private readonly initialCenter: readonly [number, number];
38
+ private readonly radius: number;
39
+ private readonly deadZone: number;
40
+ private readonly maxDistance: number;
41
+ private readonly fixed: boolean;
42
+ private readonly returnToCenter: boolean;
43
+ private centerRef: readonly [number, number];
44
+ private stickRef: readonly [number, number];
45
+ private valueRef: readonly [number, number] = [0, 0];
46
+ private activeTouchId: number | null = null;
47
+ private active = false;
48
+ private consumedTouches = 0;
49
+
50
+ constructor(config: VirtualJoystickConfig) {
51
+ this.initialCenter = config.center;
52
+ this.centerRef = config.center;
53
+ this.stickRef = config.center;
54
+ this.radius = positive(config.radius, 64);
55
+ this.deadZone = clamp(config.deadZone ?? 0.15, 0, 0.95);
56
+ this.maxDistance = positive(config.maxDistance, this.radius * 0.8);
57
+ this.fixed = config.fixed ?? true;
58
+ this.returnToCenter = config.returnToCenter ?? true;
59
+ }
60
+
61
+ touchStart(touch: VirtualTouchPoint): boolean {
62
+ if (this.activeTouchId !== null) return false;
63
+ if (distance([touch.x, touch.y], this.centerRef) > this.radius) return false;
64
+ this.activeTouchId = touch.id;
65
+ this.active = true;
66
+ this.consumedTouches += 1;
67
+ if (!this.fixed) this.centerRef = [touch.x, touch.y];
68
+ this.updateStick([touch.x, touch.y]);
69
+ return true;
70
+ }
71
+
72
+ touchMove(touch: VirtualTouchPoint): boolean {
73
+ if (touch.id !== this.activeTouchId) return false;
74
+ this.updateStick([touch.x, touch.y]);
75
+ return true;
76
+ }
77
+
78
+ touchEnd(touch: VirtualTouchPoint): boolean {
79
+ if (touch.id !== this.activeTouchId) return false;
80
+ this.activeTouchId = null;
81
+ this.active = false;
82
+ if (this.returnToCenter) {
83
+ this.stickRef = this.centerRef;
84
+ this.valueRef = [0, 0];
85
+ }
86
+ if (!this.fixed) this.centerRef = this.initialCenter;
87
+ return true;
88
+ }
89
+
90
+ snapshot(): VirtualTouchJoystickSnapshot {
91
+ return {
92
+ source: "origin-master-touch-joystick-virtual-input-adapted",
93
+ active: this.active,
94
+ activeTouchId: this.activeTouchId,
95
+ center: [round(this.centerRef[0]), round(this.centerRef[1])],
96
+ stick: [round(this.stickRef[0]), round(this.stickRef[1])],
97
+ value: [round(this.valueRef[0]), round(this.valueRef[1])],
98
+ magnitude: round(Math.hypot(this.valueRef[0], this.valueRef[1])),
99
+ deadZone: this.deadZone,
100
+ consumedTouches: this.consumedTouches,
101
+ evidence: {
102
+ oldCodebasePort: true,
103
+ virtualJoystick: true,
104
+ deadZone: true,
105
+ clampedMagnitude: Math.hypot(this.valueRef[0], this.valueRef[1]) <= 1.0001,
106
+ returnToCenter: this.returnToCenter,
107
+ floatingCenter: !this.fixed
108
+ }
109
+ };
110
+ }
111
+
112
+ private updateStick(touch: readonly [number, number]): void {
113
+ const dx = touch[0] - this.centerRef[0];
114
+ const dy = touch[1] - this.centerRef[1];
115
+ const rawDistance = Math.hypot(dx, dy);
116
+ if (rawDistance === 0) {
117
+ this.stickRef = this.centerRef;
118
+ this.valueRef = [0, 0];
119
+ return;
120
+ }
121
+ const clampedDistance = Math.min(rawDistance, this.maxDistance);
122
+ const nx = dx / rawDistance;
123
+ const ny = dy / rawDistance;
124
+ this.stickRef = [this.centerRef[0] + nx * clampedDistance, this.centerRef[1] + ny * clampedDistance];
125
+ const normalized = clampedDistance / this.maxDistance;
126
+ if (normalized < this.deadZone) {
127
+ this.valueRef = [0, 0];
128
+ return;
129
+ }
130
+ const remapped = Math.min(1, (normalized - this.deadZone) / (1 - this.deadZone));
131
+ this.valueRef = [nx * remapped, ny * remapped];
132
+ }
133
+ }
134
+
135
+ export function sampleVirtualTouchJoystickFixture(): {
136
+ readonly active: VirtualTouchJoystickSnapshot;
137
+ readonly released: VirtualTouchJoystickSnapshot;
138
+ } {
139
+ const joystick = new VirtualTouchJoystick({
140
+ center: [96, 420],
141
+ radius: 72,
142
+ deadZone: 0.2,
143
+ maxDistance: 56,
144
+ fixed: false,
145
+ returnToCenter: true
146
+ });
147
+ joystick.touchStart({ id: 7, x: 90, y: 416 });
148
+ joystick.touchMove({ id: 7, x: 154, y: 376 });
149
+ const active = joystick.snapshot();
150
+ joystick.touchEnd({ id: 7, x: 154, y: 376 });
151
+ return { active, released: joystick.snapshot() };
152
+ }
153
+
154
+ function positive(value: unknown, fallback: number): number {
155
+ const number = Number(value);
156
+ return Number.isFinite(number) && number > 0 ? number : fallback;
157
+ }
158
+
159
+ function distance(a: readonly [number, number], b: readonly [number, number]): number {
160
+ return Math.hypot(a[0] - b[0], a[1] - b[1]);
161
+ }
162
+
163
+ function clamp(value: number, min: number, max: number): number {
164
+ return Math.max(min, Math.min(max, value));
165
+ }
166
+
167
+ function round(value: number): number {
168
+ return Number(value.toFixed(4));
169
+ }