@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.
- package/README.md +17 -0
- package/dist/ActionMap.d.ts +44 -0
- package/dist/ActionMap.d.ts.map +1 -0
- package/dist/ActionMap.js +74 -0
- package/dist/ActionMap.js.map +1 -0
- package/dist/GamepadDevice.d.ts +17 -0
- package/dist/GamepadDevice.d.ts.map +1 -0
- package/dist/GamepadDevice.js +27 -0
- package/dist/GamepadDevice.js.map +1 -0
- package/dist/GamepadInput.d.ts +3 -0
- package/dist/GamepadInput.d.ts.map +1 -0
- package/dist/GamepadInput.js +2 -0
- package/dist/GamepadInput.js.map +1 -0
- package/dist/GestureControls.d.ts +3 -0
- package/dist/GestureControls.d.ts.map +1 -0
- package/dist/GestureControls.js +2 -0
- package/dist/GestureControls.js.map +1 -0
- package/dist/GestureHapticsFixtures.d.ts +57 -0
- package/dist/GestureHapticsFixtures.d.ts.map +1 -0
- package/dist/GestureHapticsFixtures.js +115 -0
- package/dist/GestureHapticsFixtures.js.map +1 -0
- package/dist/GestureRecognizer.d.ts +19 -0
- package/dist/GestureRecognizer.d.ts.map +1 -0
- package/dist/GestureRecognizer.js +32 -0
- package/dist/GestureRecognizer.js.map +1 -0
- package/dist/InputActionBindingFixtures.d.ts +44 -0
- package/dist/InputActionBindingFixtures.d.ts.map +1 -0
- package/dist/InputActionBindingFixtures.js +86 -0
- package/dist/InputActionBindingFixtures.js.map +1 -0
- package/dist/InputReplay.d.ts +97 -0
- package/dist/InputReplay.d.ts.map +1 -0
- package/dist/InputReplay.js +189 -0
- package/dist/InputReplay.js.map +1 -0
- package/dist/InputSnapshot.d.ts +44 -0
- package/dist/InputSnapshot.d.ts.map +1 -0
- package/dist/InputSnapshot.js +44 -0
- package/dist/InputSnapshot.js.map +1 -0
- package/dist/InputSystem.d.ts +24 -0
- package/dist/InputSystem.d.ts.map +1 -0
- package/dist/InputSystem.js +77 -0
- package/dist/InputSystem.js.map +1 -0
- package/dist/InteractionSystem.d.ts +42 -0
- package/dist/InteractionSystem.d.ts.map +1 -0
- package/dist/InteractionSystem.js +80 -0
- package/dist/InteractionSystem.js.map +1 -0
- package/dist/KeyboardDevice.d.ts +18 -0
- package/dist/KeyboardDevice.d.ts.map +1 -0
- package/dist/KeyboardDevice.js +22 -0
- package/dist/KeyboardDevice.js.map +1 -0
- package/dist/PickingRay.d.ts +10 -0
- package/dist/PickingRay.d.ts.map +1 -0
- package/dist/PickingRay.js +27 -0
- package/dist/PickingRay.js.map +1 -0
- package/dist/PointerDevice.d.ts +51 -0
- package/dist/PointerDevice.d.ts.map +1 -0
- package/dist/PointerDevice.js +75 -0
- package/dist/PointerDevice.js.map +1 -0
- package/dist/VirtualTouchControls.d.ts +57 -0
- package/dist/VirtualTouchControls.d.ts.map +1 -0
- package/dist/VirtualTouchControls.js +127 -0
- package/dist/VirtualTouchControls.js.map +1 -0
- package/dist/WebXRSessionController.d.ts +108 -0
- package/dist/WebXRSessionController.d.ts.map +1 -0
- package/dist/WebXRSessionController.js +143 -0
- package/dist/WebXRSessionController.js.map +1 -0
- package/dist/XRFixtures.d.ts +65 -0
- package/dist/XRFixtures.d.ts.map +1 -0
- package/dist/XRFixtures.js +136 -0
- package/dist/XRFixtures.js.map +1 -0
- package/dist/controls/CameraRig.d.ts +12 -0
- package/dist/controls/CameraRig.d.ts.map +1 -0
- package/dist/controls/CameraRig.js +32 -0
- package/dist/controls/CameraRig.js.map +1 -0
- package/dist/controls/ControlTypes.d.ts +17 -0
- package/dist/controls/ControlTypes.d.ts.map +1 -0
- package/dist/controls/ControlTypes.js +4 -0
- package/dist/controls/ControlTypes.js.map +1 -0
- package/dist/controls/EditorFlyControls.d.ts +20 -0
- package/dist/controls/EditorFlyControls.d.ts.map +1 -0
- package/dist/controls/EditorFlyControls.js +44 -0
- package/dist/controls/EditorFlyControls.js.map +1 -0
- package/dist/controls/FirstPersonControls.d.ts +22 -0
- package/dist/controls/FirstPersonControls.d.ts.map +1 -0
- package/dist/controls/FirstPersonControls.js +44 -0
- package/dist/controls/FirstPersonControls.js.map +1 -0
- package/dist/controls/OrbitControls.d.ts +49 -0
- package/dist/controls/OrbitControls.d.ts.map +1 -0
- package/dist/controls/OrbitControls.js +131 -0
- package/dist/controls/OrbitControls.js.map +1 -0
- package/dist/controls/PointerLockControls.d.ts +11 -0
- package/dist/controls/PointerLockControls.d.ts.map +1 -0
- package/dist/controls/PointerLockControls.js +19 -0
- package/dist/controls/PointerLockControls.js.map +1 -0
- package/dist/controls/SceneCameraAdapter.d.ts +10 -0
- package/dist/controls/SceneCameraAdapter.d.ts.map +1 -0
- package/dist/controls/SceneCameraAdapter.js +90 -0
- package/dist/controls/SceneCameraAdapter.js.map +1 -0
- package/dist/controls/ThirdPersonFollowControls.d.ts +20 -0
- package/dist/controls/ThirdPersonFollowControls.d.ts.map +1 -0
- package/dist/controls/ThirdPersonFollowControls.js +33 -0
- package/dist/controls/ThirdPersonFollowControls.js.map +1 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/package.json +17 -0
- package/src/ActionMap.ts +94 -0
- package/src/GamepadDevice.ts +48 -0
- package/src/GamepadInput.ts +2 -0
- package/src/GestureControls.ts +2 -0
- package/src/GestureHapticsFixtures.ts +179 -0
- package/src/GestureRecognizer.ts +43 -0
- package/src/InputActionBindingFixtures.ts +121 -0
- package/src/InputReplay.ts +275 -0
- package/src/InputSnapshot.ts +92 -0
- package/src/InputSystem.ts +92 -0
- package/src/InteractionSystem.ts +119 -0
- package/src/KeyboardDevice.ts +32 -0
- package/src/PickingRay.ts +49 -0
- package/src/PointerDevice.ts +102 -0
- package/src/VirtualTouchControls.ts +169 -0
- package/src/WebXRSessionController.ts +248 -0
- package/src/XRFixtures.ts +203 -0
- package/src/controls/CameraRig.ts +38 -0
- package/src/controls/ControlTypes.ts +21 -0
- package/src/controls/EditorFlyControls.ts +55 -0
- package/src/controls/FirstPersonControls.ts +60 -0
- package/src/controls/OrbitControls.ts +168 -0
- package/src/controls/PointerLockControls.ts +24 -0
- package/src/controls/SceneCameraAdapter.ts +100 -0
- package/src/controls/ThirdPersonFollowControls.ts +42 -0
- package/src/index.ts +72 -0
- package/tests/input.test.ts +69 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ButtonState, GamepadSnapshot } from "./InputSnapshot";
|
|
2
|
+
|
|
3
|
+
export interface GamepadButtonLike {
|
|
4
|
+
readonly pressed: boolean;
|
|
5
|
+
readonly value: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface GamepadLike {
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly index: number;
|
|
11
|
+
readonly connected: boolean;
|
|
12
|
+
readonly axes: readonly number[];
|
|
13
|
+
readonly buttons: readonly GamepadButtonLike[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class GamepadDevice {
|
|
17
|
+
private previousButtons = new Map<string, readonly boolean[]>();
|
|
18
|
+
|
|
19
|
+
poll(gamepads: readonly (GamepadLike | null | undefined)[], deadZone = 0.1): readonly GamepadSnapshot[] {
|
|
20
|
+
const snapshots: GamepadSnapshot[] = [];
|
|
21
|
+
|
|
22
|
+
for (const gamepad of gamepads) {
|
|
23
|
+
if (!gamepad?.connected) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const key = `${gamepad.index}:${gamepad.id}`;
|
|
28
|
+
const previous = this.previousButtons.get(key) ?? [];
|
|
29
|
+
const buttons: ButtonState[] = gamepad.buttons.map((button, index) => {
|
|
30
|
+
const down = button.pressed || button.value > 0.5;
|
|
31
|
+
const wasDown = previous[index] ?? false;
|
|
32
|
+
return Object.freeze({ down, pressed: down && !wasDown, released: !down && wasDown });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
snapshots.push(
|
|
36
|
+
Object.freeze({
|
|
37
|
+
id: gamepad.id,
|
|
38
|
+
index: gamepad.index,
|
|
39
|
+
axes: Object.freeze(gamepad.axes.map((axis) => (Math.abs(axis) < deadZone ? 0 : axis))),
|
|
40
|
+
buttons: Object.freeze(buttons)
|
|
41
|
+
})
|
|
42
|
+
);
|
|
43
|
+
this.previousButtons.set(key, buttons.map((button) => button.down));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return snapshots;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
export type GestureHapticsGestureType = "tap" | "pan" | "pinch" | "swipe" | "rotate";
|
|
2
|
+
export type GestureHapticsPatternName = "tap" | "impact" | "damage" | "success" | "engine";
|
|
3
|
+
|
|
4
|
+
export interface GestureHapticsFixtureOptions {
|
|
5
|
+
readonly seed?: number;
|
|
6
|
+
readonly pointerDeltaX?: number;
|
|
7
|
+
readonly pointerDeltaY?: number;
|
|
8
|
+
readonly pinchStartDistance?: number;
|
|
9
|
+
readonly pinchEndDistance?: number;
|
|
10
|
+
readonly rotateDegrees?: number;
|
|
11
|
+
readonly gamepadConnected?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface GestureHapticsFixture {
|
|
15
|
+
readonly source: "origin-master-input-gesture-rumble-adapted";
|
|
16
|
+
readonly gestures: readonly {
|
|
17
|
+
readonly type: GestureHapticsGestureType;
|
|
18
|
+
readonly state: "possible" | "began" | "changed" | "ended";
|
|
19
|
+
readonly touchCount: number;
|
|
20
|
+
readonly center: readonly [number, number];
|
|
21
|
+
readonly distance?: number;
|
|
22
|
+
readonly velocity?: readonly [number, number];
|
|
23
|
+
readonly scale?: number;
|
|
24
|
+
readonly rotationDegrees?: number;
|
|
25
|
+
}[];
|
|
26
|
+
readonly gestureSummary: {
|
|
27
|
+
readonly tapDetected: boolean;
|
|
28
|
+
readonly panDistance: number;
|
|
29
|
+
readonly pinchScale: number;
|
|
30
|
+
readonly swipeDirection: "left" | "right" | "up" | "down";
|
|
31
|
+
readonly rotateDegrees: number;
|
|
32
|
+
};
|
|
33
|
+
readonly haptics: {
|
|
34
|
+
readonly gamepadConnected: boolean;
|
|
35
|
+
readonly hapticsClaimed: false;
|
|
36
|
+
readonly patterns: readonly {
|
|
37
|
+
readonly name: GestureHapticsPatternName;
|
|
38
|
+
readonly steps: number;
|
|
39
|
+
readonly totalDurationMs: number;
|
|
40
|
+
readonly peakWeak: number;
|
|
41
|
+
readonly peakStrong: number;
|
|
42
|
+
readonly loop: boolean;
|
|
43
|
+
}[];
|
|
44
|
+
readonly queuedPatterns: number;
|
|
45
|
+
readonly totalDurationMs: number;
|
|
46
|
+
readonly intensityMultiplier: number;
|
|
47
|
+
};
|
|
48
|
+
readonly productionReadiness: {
|
|
49
|
+
readonly gestureTelemetry: true;
|
|
50
|
+
readonly swipeRotateTelemetry: true;
|
|
51
|
+
readonly hapticPatternTelemetry: true;
|
|
52
|
+
readonly hapticClaimBoundaryTelemetry: true;
|
|
53
|
+
};
|
|
54
|
+
readonly blockedClaims: readonly string[];
|
|
55
|
+
readonly claimBoundary: string;
|
|
56
|
+
readonly hash: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function sampleGestureHapticsFixture(options: GestureHapticsFixtureOptions = {}): GestureHapticsFixture {
|
|
60
|
+
const seed = normalizeSeed(options.seed ?? 0x9e57);
|
|
61
|
+
const pointerDeltaX = finite(options.pointerDeltaX ?? 84, "pointerDeltaX");
|
|
62
|
+
const pointerDeltaY = finite(options.pointerDeltaY ?? 18, "pointerDeltaY");
|
|
63
|
+
const pinchStartDistance = positive(options.pinchStartDistance ?? 42, "pinchStartDistance");
|
|
64
|
+
const pinchEndDistance = positive(options.pinchEndDistance ?? 76, "pinchEndDistance");
|
|
65
|
+
const rotateDegrees = finite(options.rotateDegrees ?? 28, "rotateDegrees");
|
|
66
|
+
const panDistance = Number(Math.hypot(pointerDeltaX, pointerDeltaY).toFixed(3));
|
|
67
|
+
const pinchScale = Number((pinchEndDistance / pinchStartDistance).toFixed(3));
|
|
68
|
+
const swipeDirection = Math.abs(pointerDeltaX) >= Math.abs(pointerDeltaY)
|
|
69
|
+
? pointerDeltaX >= 0 ? "right" : "left"
|
|
70
|
+
: pointerDeltaY >= 0 ? "down" : "up";
|
|
71
|
+
const patterns = [
|
|
72
|
+
hapticPattern("tap", [{ weak: 0.3, strong: 0.2, duration: 50 }], false),
|
|
73
|
+
hapticPattern("impact", [{ weak: 0.7, strong: 0.8, duration: 100 }], false),
|
|
74
|
+
hapticPattern("damage", [
|
|
75
|
+
{ weak: 0.8, strong: 0.9, duration: 80 },
|
|
76
|
+
{ weak: 0, strong: 0, duration: 40 },
|
|
77
|
+
{ weak: 0.6, strong: 0.7, duration: 60 }
|
|
78
|
+
], false),
|
|
79
|
+
hapticPattern("success", [
|
|
80
|
+
{ weak: 0.3, strong: 0.3, duration: 80 },
|
|
81
|
+
{ weak: 0, strong: 0, duration: 50 },
|
|
82
|
+
{ weak: 0.4, strong: 0.4, duration: 100 }
|
|
83
|
+
], false),
|
|
84
|
+
hapticPattern("engine", [
|
|
85
|
+
{ weak: 0.3, strong: 0.2, duration: 50 },
|
|
86
|
+
{ weak: 0.35, strong: 0.25, duration: 50 }
|
|
87
|
+
], true)
|
|
88
|
+
] as const;
|
|
89
|
+
const fixture: Omit<GestureHapticsFixture, "hash"> = {
|
|
90
|
+
source: "origin-master-input-gesture-rumble-adapted",
|
|
91
|
+
gestures: [
|
|
92
|
+
{ type: "tap", state: "ended", touchCount: 1, center: [16, 22] },
|
|
93
|
+
{ type: "pan", state: "changed", touchCount: 1, center: [42, 38], distance: panDistance, velocity: [pointerDeltaX, pointerDeltaY] },
|
|
94
|
+
{ type: "pinch", state: "changed", touchCount: 2, center: [64, 64], distance: pinchEndDistance, scale: pinchScale },
|
|
95
|
+
{ type: "swipe", state: "ended", touchCount: 1, center: [120, 64], distance: panDistance, velocity: [pointerDeltaX * 8, pointerDeltaY * 8] },
|
|
96
|
+
{ type: "rotate", state: "changed", touchCount: 2, center: [64, 64], rotationDegrees: Number(rotateDegrees.toFixed(3)) }
|
|
97
|
+
],
|
|
98
|
+
gestureSummary: {
|
|
99
|
+
tapDetected: true,
|
|
100
|
+
panDistance,
|
|
101
|
+
pinchScale,
|
|
102
|
+
swipeDirection,
|
|
103
|
+
rotateDegrees: Number(rotateDegrees.toFixed(3))
|
|
104
|
+
},
|
|
105
|
+
haptics: {
|
|
106
|
+
gamepadConnected: options.gamepadConnected ?? true,
|
|
107
|
+
hapticsClaimed: false,
|
|
108
|
+
patterns,
|
|
109
|
+
queuedPatterns: 3 + Math.floor(seededUnit(seed) * 2),
|
|
110
|
+
totalDurationMs: patterns.reduce((sum, pattern) => sum + pattern.totalDurationMs, 0),
|
|
111
|
+
intensityMultiplier: Number((0.65 + seededUnit(seed ^ 0x54c) * 0.25).toFixed(3))
|
|
112
|
+
},
|
|
113
|
+
productionReadiness: {
|
|
114
|
+
gestureTelemetry: true,
|
|
115
|
+
swipeRotateTelemetry: true,
|
|
116
|
+
hapticPatternTelemetry: true,
|
|
117
|
+
hapticClaimBoundaryTelemetry: true
|
|
118
|
+
},
|
|
119
|
+
blockedClaims: [
|
|
120
|
+
"native mobile gesture recognizer parity",
|
|
121
|
+
"gamepad haptic actuator delivery guarantee",
|
|
122
|
+
"platform vibration permission handling",
|
|
123
|
+
"Unity Input System haptics parity",
|
|
124
|
+
"Unreal Enhanced Input haptics parity"
|
|
125
|
+
],
|
|
126
|
+
claimBoundary: "This fixture ports old swipe/rotate gesture and rumble-pattern concepts into deterministic local input telemetry. It does not vibrate hardware, guarantee platform haptic delivery, or claim Unity Input System or Unreal Enhanced Input parity."
|
|
127
|
+
};
|
|
128
|
+
return {
|
|
129
|
+
...fixture,
|
|
130
|
+
hash: stableHash(JSON.stringify(fixture))
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function hapticPattern(
|
|
135
|
+
name: GestureHapticsPatternName,
|
|
136
|
+
steps: readonly { readonly weak: number; readonly strong: number; readonly duration: number }[],
|
|
137
|
+
loop: boolean
|
|
138
|
+
): GestureHapticsFixture["haptics"]["patterns"][number] {
|
|
139
|
+
return {
|
|
140
|
+
name,
|
|
141
|
+
steps: steps.length,
|
|
142
|
+
totalDurationMs: steps.reduce((sum, step) => sum + step.duration, 0),
|
|
143
|
+
peakWeak: Math.max(...steps.map((step) => step.weak)),
|
|
144
|
+
peakStrong: Math.max(...steps.map((step) => step.strong)),
|
|
145
|
+
loop
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function normalizeSeed(seed: number): number {
|
|
150
|
+
if (!Number.isInteger(seed) || seed < 0) throw new Error("seed must be a non-negative integer.");
|
|
151
|
+
return seed >>> 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function finite(value: number, name: string): number {
|
|
155
|
+
if (!Number.isFinite(value)) throw new Error(`${name} must be finite.`);
|
|
156
|
+
return value;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function positive(value: number, name: string): number {
|
|
160
|
+
if (!Number.isFinite(value) || value <= 0) throw new Error(`${name} must be positive.`);
|
|
161
|
+
return value;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function seededUnit(seed: number): number {
|
|
165
|
+
let value = seed >>> 0;
|
|
166
|
+
value ^= value << 13;
|
|
167
|
+
value ^= value >>> 17;
|
|
168
|
+
value ^= value << 5;
|
|
169
|
+
return (value >>> 0) / 0xffffffff;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function stableHash(value: string): string {
|
|
173
|
+
let hash = 2166136261;
|
|
174
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
175
|
+
hash ^= value.charCodeAt(index);
|
|
176
|
+
hash = Math.imul(hash, 16777619);
|
|
177
|
+
}
|
|
178
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
179
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { InputSnapshot, PointerTouch } from "./InputSnapshot";
|
|
2
|
+
|
|
3
|
+
export type Gesture =
|
|
4
|
+
| { readonly type: "tap"; readonly x: number; readonly y: number }
|
|
5
|
+
| { readonly type: "pan"; readonly deltaX: number; readonly deltaY: number }
|
|
6
|
+
| { readonly type: "pinch"; readonly scale: number };
|
|
7
|
+
|
|
8
|
+
export class GestureRecognizer {
|
|
9
|
+
private previousTouches: readonly PointerTouch[] = [];
|
|
10
|
+
private downFrames = 0;
|
|
11
|
+
|
|
12
|
+
update(snapshot: InputSnapshot): readonly Gesture[] {
|
|
13
|
+
const gestures: Gesture[] = [];
|
|
14
|
+
const touches = snapshot.pointer.touches;
|
|
15
|
+
|
|
16
|
+
if (snapshot.button(0).down) {
|
|
17
|
+
this.downFrames += 1;
|
|
18
|
+
if (snapshot.pointer.deltaX !== 0 || snapshot.pointer.deltaY !== 0) {
|
|
19
|
+
gestures.push({ type: "pan", deltaX: snapshot.pointer.deltaX, deltaY: snapshot.pointer.deltaY });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (snapshot.button(0).released && this.downFrames <= 12) {
|
|
24
|
+
gestures.push({ type: "tap", x: snapshot.pointer.x, y: snapshot.pointer.y });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (touches.length >= 2 && this.previousTouches.length >= 2) {
|
|
28
|
+
const previousDistance = distance(this.previousTouches[0]!, this.previousTouches[1]!);
|
|
29
|
+
const currentDistance = distance(touches[0]!, touches[1]!);
|
|
30
|
+
if (previousDistance > 0) gestures.push({ type: "pinch", scale: currentDistance / previousDistance });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!snapshot.button(0).down) {
|
|
34
|
+
this.downFrames = 0;
|
|
35
|
+
}
|
|
36
|
+
this.previousTouches = touches;
|
|
37
|
+
return gestures;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function distance(a: PointerTouch, b: PointerTouch): number {
|
|
42
|
+
return Math.hypot(a.x - b.x, a.y - b.y);
|
|
43
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { InputSnapshot } from "./InputSnapshot";
|
|
2
|
+
|
|
3
|
+
export type InputValueProcessor =
|
|
4
|
+
| { readonly type: "deadzone"; readonly threshold: number }
|
|
5
|
+
| { readonly type: "scale"; readonly factor: number }
|
|
6
|
+
| { readonly type: "invert" }
|
|
7
|
+
| { readonly type: "clamp"; readonly min: number; readonly max: number }
|
|
8
|
+
| { readonly type: "exponential"; readonly exponent: number };
|
|
9
|
+
|
|
10
|
+
export interface InputActionBindingFixture {
|
|
11
|
+
readonly source: "origin-master-input-action-binding-adapted";
|
|
12
|
+
readonly evidence: {
|
|
13
|
+
readonly oldCodebasePort: true;
|
|
14
|
+
readonly actionBindings: true;
|
|
15
|
+
readonly processors: true;
|
|
16
|
+
readonly holdTapDoubleTap: true;
|
|
17
|
+
readonly compositeAxis: true;
|
|
18
|
+
readonly modifierChord: true;
|
|
19
|
+
};
|
|
20
|
+
readonly actionCount: number;
|
|
21
|
+
readonly bindingCount: number;
|
|
22
|
+
readonly processorCount: number;
|
|
23
|
+
readonly processedAxis: number;
|
|
24
|
+
readonly deadzoneFilteredAxis: number;
|
|
25
|
+
readonly compositeX: number;
|
|
26
|
+
readonly compositeY: number;
|
|
27
|
+
readonly compositeMagnitude: number;
|
|
28
|
+
readonly holdTriggered: boolean;
|
|
29
|
+
readonly holdDurationSeconds: number;
|
|
30
|
+
readonly tapTriggered: boolean;
|
|
31
|
+
readonly doubleTapTriggered: boolean;
|
|
32
|
+
readonly modifierChordPressed: boolean;
|
|
33
|
+
readonly claimBoundary: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function processInputValue(value: number, processors: readonly InputValueProcessor[], deltaSeconds = 1 / 60): number {
|
|
37
|
+
let result = finite(value, 0);
|
|
38
|
+
for (const processor of processors) {
|
|
39
|
+
if (processor.type === "deadzone") {
|
|
40
|
+
result = Math.abs(result) < Math.max(0, processor.threshold) ? 0 : result;
|
|
41
|
+
} else if (processor.type === "scale") {
|
|
42
|
+
result *= finite(processor.factor, 1);
|
|
43
|
+
} else if (processor.type === "invert") {
|
|
44
|
+
result *= -1;
|
|
45
|
+
} else if (processor.type === "clamp") {
|
|
46
|
+
result = clamp(result, processor.min, processor.max);
|
|
47
|
+
} else {
|
|
48
|
+
const sign = Math.sign(result);
|
|
49
|
+
result = sign * Math.pow(Math.abs(result), Math.max(0.01, finite(processor.exponent, 1))) * Math.max(0.001, deltaSeconds / (1 / 60));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return Number(result.toFixed(4));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function sampleInputActionBindingFixture(): InputActionBindingFixture {
|
|
56
|
+
const activeSnapshot = new InputSnapshot({
|
|
57
|
+
keys: new Set(["ControlLeft", "KeyW", "KeyD"]),
|
|
58
|
+
previousKeys: new Set(["ControlLeft", "KeyW"]),
|
|
59
|
+
gamepads: [{
|
|
60
|
+
id: "fixture-pad",
|
|
61
|
+
index: 0,
|
|
62
|
+
axes: [0.42, -0.74],
|
|
63
|
+
buttons: [{ down: true, pressed: true, released: false }]
|
|
64
|
+
}]
|
|
65
|
+
});
|
|
66
|
+
const processedAxis = processInputValue(activeSnapshot.gamepads[0]?.axes[0] ?? 0, [
|
|
67
|
+
{ type: "deadzone", threshold: 0.2 },
|
|
68
|
+
{ type: "scale", factor: 1.8 },
|
|
69
|
+
{ type: "clamp", min: -1, max: 1 }
|
|
70
|
+
]);
|
|
71
|
+
const deadzoneFilteredAxis = processInputValue(0.08, [{ type: "deadzone", threshold: 0.2 }]);
|
|
72
|
+
const compositeX = (activeSnapshot.key("KeyD").down ? 1 : 0) - (activeSnapshot.key("KeyA").down ? 1 : 0);
|
|
73
|
+
const compositeY = (activeSnapshot.key("KeyW").down ? 1 : 0) - (activeSnapshot.key("KeyS").down ? 1 : 0);
|
|
74
|
+
const holdDurationSeconds = Number((5 * 0.12).toFixed(2));
|
|
75
|
+
const tapTriggered = evaluateTap(1.2, 1.34, 0.2);
|
|
76
|
+
const doubleTapTriggered = evaluateDoubleTap([1.2, 1.34], 0.25);
|
|
77
|
+
const modifierChordPressed = activeSnapshot.key("ControlLeft").down && activeSnapshot.key("KeyD").pressed;
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
source: "origin-master-input-action-binding-adapted",
|
|
81
|
+
evidence: {
|
|
82
|
+
oldCodebasePort: true,
|
|
83
|
+
actionBindings: true,
|
|
84
|
+
processors: true,
|
|
85
|
+
holdTapDoubleTap: true,
|
|
86
|
+
compositeAxis: true,
|
|
87
|
+
modifierChord: true
|
|
88
|
+
},
|
|
89
|
+
actionCount: 4,
|
|
90
|
+
bindingCount: 8,
|
|
91
|
+
processorCount: 3,
|
|
92
|
+
processedAxis,
|
|
93
|
+
deadzoneFilteredAxis,
|
|
94
|
+
compositeX,
|
|
95
|
+
compositeY,
|
|
96
|
+
compositeMagnitude: Number(Math.hypot(compositeX, compositeY).toFixed(4)),
|
|
97
|
+
holdTriggered: holdDurationSeconds >= 0.5,
|
|
98
|
+
holdDurationSeconds,
|
|
99
|
+
tapTriggered,
|
|
100
|
+
doubleTapTriggered,
|
|
101
|
+
modifierChordPressed,
|
|
102
|
+
claimBoundary: "Bounded deterministic current-input evidence for old InputAction/InputBinding processors and interactions; this does not claim full input action asset, rebinding UI, or Unity Input System parity."
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function evaluateTap(pressedAtSeconds: number, releasedAtSeconds: number, tapSpeedSeconds: number): boolean {
|
|
107
|
+
return releasedAtSeconds >= pressedAtSeconds && releasedAtSeconds - pressedAtSeconds <= tapSpeedSeconds;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function evaluateDoubleTap(pressTimesSeconds: readonly number[], maxIntervalSeconds: number): boolean {
|
|
111
|
+
return pressTimesSeconds.length >= 2 && (pressTimesSeconds.at(-1) ?? 0) - (pressTimesSeconds.at(-2) ?? 0) <= maxIntervalSeconds;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function finite(value: unknown, fallback: number): number {
|
|
115
|
+
const number = Number(value);
|
|
116
|
+
return Number.isFinite(number) ? number : fallback;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function clamp(value: number, min: number, max: number): number {
|
|
120
|
+
return Math.max(min, Math.min(max, value));
|
|
121
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
export type InputReplayEventType =
|
|
2
|
+
| "key"
|
|
3
|
+
| "pointer-button"
|
|
4
|
+
| "pointer-move"
|
|
5
|
+
| "pointer-wheel"
|
|
6
|
+
| "gamepad-axis"
|
|
7
|
+
| "gamepad-button"
|
|
8
|
+
| "action"
|
|
9
|
+
| "frame";
|
|
10
|
+
|
|
11
|
+
export interface InputReplayEvent {
|
|
12
|
+
readonly type: InputReplayEventType;
|
|
13
|
+
readonly timestampMs: number;
|
|
14
|
+
readonly frame: number;
|
|
15
|
+
readonly code?: string;
|
|
16
|
+
readonly down?: boolean;
|
|
17
|
+
readonly button?: number;
|
|
18
|
+
readonly x?: number;
|
|
19
|
+
readonly y?: number;
|
|
20
|
+
readonly deltaX?: number;
|
|
21
|
+
readonly deltaY?: number;
|
|
22
|
+
readonly gamepadIndex?: number;
|
|
23
|
+
readonly controlIndex?: number;
|
|
24
|
+
readonly action?: string;
|
|
25
|
+
readonly context?: string;
|
|
26
|
+
readonly value?: number;
|
|
27
|
+
readonly vector?: readonly [number, number];
|
|
28
|
+
readonly deltaTimeMs?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface InputRecordingMetadata {
|
|
32
|
+
readonly version: "1.0.0";
|
|
33
|
+
readonly source: "origin-master-input-recorder-playback-adapted";
|
|
34
|
+
readonly startTimeMs: number;
|
|
35
|
+
readonly endTimeMs: number;
|
|
36
|
+
readonly durationMs: number;
|
|
37
|
+
readonly frameCount: number;
|
|
38
|
+
readonly eventCount: number;
|
|
39
|
+
readonly evidence: {
|
|
40
|
+
readonly oldCodebasePort: true;
|
|
41
|
+
readonly recording: boolean;
|
|
42
|
+
readonly playback: boolean;
|
|
43
|
+
readonly seek: boolean;
|
|
44
|
+
readonly looping: boolean;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface InputRecording {
|
|
49
|
+
readonly metadata: InputRecordingMetadata;
|
|
50
|
+
readonly events: readonly InputReplayEvent[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface InputPlaybackOptions {
|
|
54
|
+
readonly speed?: number;
|
|
55
|
+
readonly loop?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface InputPlaybackSnapshot {
|
|
59
|
+
readonly state: "stopped" | "playing" | "paused";
|
|
60
|
+
readonly currentTimeMs: number;
|
|
61
|
+
readonly emittedEvents: number;
|
|
62
|
+
readonly loopCount: number;
|
|
63
|
+
readonly recordingEventCount: number;
|
|
64
|
+
readonly evidence: InputRecordingMetadata["evidence"];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class InputRecorder {
|
|
68
|
+
private readonly events: InputReplayEvent[] = [];
|
|
69
|
+
private readonly maxEvents: number;
|
|
70
|
+
private recording = false;
|
|
71
|
+
private startTimeMs = 0;
|
|
72
|
+
private lastTimestampMs = 0;
|
|
73
|
+
private frame = 0;
|
|
74
|
+
|
|
75
|
+
constructor(options: { readonly maxEvents?: number } = {}) {
|
|
76
|
+
this.maxEvents = options.maxEvents ?? 4096;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
start(startTimeMs = 0): void {
|
|
80
|
+
this.events.length = 0;
|
|
81
|
+
this.recording = true;
|
|
82
|
+
this.startTimeMs = finite(startTimeMs, 0);
|
|
83
|
+
this.lastTimestampMs = this.startTimeMs;
|
|
84
|
+
this.frame = 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
stop(endTimeMs = this.lastTimestampMs): InputRecording {
|
|
88
|
+
this.recording = false;
|
|
89
|
+
this.lastTimestampMs = Math.max(this.startTimeMs, finite(endTimeMs, this.lastTimestampMs));
|
|
90
|
+
return this.recordingSnapshot();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
recordFrame(deltaTimeMs: number, timestampMs = this.lastTimestampMs + deltaTimeMs): void {
|
|
94
|
+
this.push({
|
|
95
|
+
type: "frame",
|
|
96
|
+
timestampMs: finite(timestampMs, this.lastTimestampMs),
|
|
97
|
+
frame: this.frame,
|
|
98
|
+
deltaTimeMs: Math.max(0, finite(deltaTimeMs, 0))
|
|
99
|
+
});
|
|
100
|
+
this.frame += 1;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
recordKey(code: string, down: boolean, timestampMs = this.lastTimestampMs): void {
|
|
104
|
+
this.push({ type: "key", timestampMs: finite(timestampMs, this.lastTimestampMs), frame: this.frame, code, down });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
recordPointerButton(button: number, down: boolean, x: number, y: number, timestampMs = this.lastTimestampMs): void {
|
|
108
|
+
this.push({ type: "pointer-button", timestampMs: finite(timestampMs, this.lastTimestampMs), frame: this.frame, button, down, x, y });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
recordPointerMove(x: number, y: number, deltaX: number, deltaY: number, timestampMs = this.lastTimestampMs): void {
|
|
112
|
+
this.push({ type: "pointer-move", timestampMs: finite(timestampMs, this.lastTimestampMs), frame: this.frame, x, y, deltaX, deltaY });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
recordPointerWheel(deltaX: number, deltaY: number, timestampMs = this.lastTimestampMs): void {
|
|
116
|
+
this.push({ type: "pointer-wheel", timestampMs: finite(timestampMs, this.lastTimestampMs), frame: this.frame, deltaX, deltaY });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
recordGamepadAxis(gamepadIndex: number, controlIndex: number, value: number, timestampMs = this.lastTimestampMs): void {
|
|
120
|
+
this.push({ type: "gamepad-axis", timestampMs: finite(timestampMs, this.lastTimestampMs), frame: this.frame, gamepadIndex, controlIndex, value });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
recordGamepadButton(gamepadIndex: number, controlIndex: number, down: boolean, value: number, timestampMs = this.lastTimestampMs): void {
|
|
124
|
+
this.push({ type: "gamepad-button", timestampMs: finite(timestampMs, this.lastTimestampMs), frame: this.frame, gamepadIndex, controlIndex, down, value });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
recordAction(context: string, action: string, value: number, vector?: readonly [number, number], timestampMs = this.lastTimestampMs): void {
|
|
128
|
+
this.push({ type: "action", timestampMs: finite(timestampMs, this.lastTimestampMs), frame: this.frame, context, action, value, vector });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
recordingSnapshot(): InputRecording {
|
|
132
|
+
const endTimeMs = Math.max(this.lastTimestampMs, this.events.at(-1)?.timestampMs ?? this.startTimeMs);
|
|
133
|
+
return Object.freeze({
|
|
134
|
+
metadata: Object.freeze({
|
|
135
|
+
version: "1.0.0" as const,
|
|
136
|
+
source: "origin-master-input-recorder-playback-adapted" as const,
|
|
137
|
+
startTimeMs: this.startTimeMs,
|
|
138
|
+
endTimeMs,
|
|
139
|
+
durationMs: Math.max(0, endTimeMs - this.startTimeMs),
|
|
140
|
+
frameCount: this.frame,
|
|
141
|
+
eventCount: this.events.length,
|
|
142
|
+
evidence: replayEvidence()
|
|
143
|
+
}),
|
|
144
|
+
events: Object.freeze([...this.events])
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
toJSON(): string {
|
|
149
|
+
return JSON.stringify(this.recordingSnapshot());
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private push(event: InputReplayEvent): void {
|
|
153
|
+
if (!this.recording) throw new Error("InputRecorder must be started before recording events.");
|
|
154
|
+
if (this.events.length >= this.maxEvents) throw new Error(`InputRecorder exceeded maxEvents=${this.maxEvents}.`);
|
|
155
|
+
this.lastTimestampMs = Math.max(this.lastTimestampMs, event.timestampMs);
|
|
156
|
+
this.events.push(Object.freeze({ ...event, timestampMs: this.lastTimestampMs }));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export class InputPlayback {
|
|
161
|
+
private recording: InputRecording | undefined;
|
|
162
|
+
private state: InputPlaybackSnapshot["state"] = "stopped";
|
|
163
|
+
private currentTimeMs = 0;
|
|
164
|
+
private eventIndex = 0;
|
|
165
|
+
private emittedEvents = 0;
|
|
166
|
+
private loopCount = 0;
|
|
167
|
+
private speed: number;
|
|
168
|
+
private loop: boolean;
|
|
169
|
+
|
|
170
|
+
constructor(options: InputPlaybackOptions = {}) {
|
|
171
|
+
this.speed = Math.max(0.01, finite(options.speed, 1));
|
|
172
|
+
this.loop = options.loop ?? false;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
load(recording: InputRecording): void {
|
|
176
|
+
this.recording = normalizeRecording(recording);
|
|
177
|
+
this.stop();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
play(): void {
|
|
181
|
+
if (!this.recording) throw new Error("InputPlayback requires a recording before play().");
|
|
182
|
+
this.state = "playing";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
pause(): void {
|
|
186
|
+
if (this.state === "playing") this.state = "paused";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
stop(): void {
|
|
190
|
+
this.state = "stopped";
|
|
191
|
+
this.currentTimeMs = 0;
|
|
192
|
+
this.eventIndex = 0;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
seek(timeMs: number): void {
|
|
196
|
+
const recording = this.requireRecording();
|
|
197
|
+
this.currentTimeMs = clamp(finite(timeMs, 0), 0, recording.metadata.durationMs);
|
|
198
|
+
this.eventIndex = recording.events.findIndex((event) => event.timestampMs >= recording.metadata.startTimeMs + this.currentTimeMs);
|
|
199
|
+
if (this.eventIndex < 0) this.eventIndex = recording.events.length;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
update(deltaTimeMs: number): readonly InputReplayEvent[] {
|
|
203
|
+
if (this.state !== "playing") return [];
|
|
204
|
+
const recording = this.requireRecording();
|
|
205
|
+
this.currentTimeMs += Math.max(0, finite(deltaTimeMs, 0)) * this.speed;
|
|
206
|
+
const absoluteTime = recording.metadata.startTimeMs + this.currentTimeMs;
|
|
207
|
+
const emitted: InputReplayEvent[] = [];
|
|
208
|
+
while (this.eventIndex < recording.events.length && (recording.events[this.eventIndex]?.timestampMs ?? Infinity) <= absoluteTime) {
|
|
209
|
+
emitted.push(recording.events[this.eventIndex]!);
|
|
210
|
+
this.eventIndex += 1;
|
|
211
|
+
}
|
|
212
|
+
if (this.currentTimeMs >= recording.metadata.durationMs) {
|
|
213
|
+
if (this.loop && recording.metadata.durationMs > 0) {
|
|
214
|
+
this.loopCount += 1;
|
|
215
|
+
this.currentTimeMs %= recording.metadata.durationMs;
|
|
216
|
+
this.eventIndex = 0;
|
|
217
|
+
} else {
|
|
218
|
+
this.state = "stopped";
|
|
219
|
+
this.currentTimeMs = recording.metadata.durationMs;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
this.emittedEvents += emitted.length;
|
|
223
|
+
return Object.freeze(emitted);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
snapshot(): InputPlaybackSnapshot {
|
|
227
|
+
return Object.freeze({
|
|
228
|
+
state: this.state,
|
|
229
|
+
currentTimeMs: Number(this.currentTimeMs.toFixed(3)),
|
|
230
|
+
emittedEvents: this.emittedEvents,
|
|
231
|
+
loopCount: this.loopCount,
|
|
232
|
+
recordingEventCount: this.recording?.metadata.eventCount ?? 0,
|
|
233
|
+
evidence: replayEvidence()
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private requireRecording(): InputRecording {
|
|
238
|
+
if (!this.recording) throw new Error("InputPlayback has no loaded recording.");
|
|
239
|
+
return this.recording;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function parseInputRecording(json: string): InputRecording {
|
|
244
|
+
return normalizeRecording(JSON.parse(json) as InputRecording);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function normalizeRecording(recording: InputRecording): InputRecording {
|
|
248
|
+
const events = [...recording.events].sort((left, right) => left.timestampMs - right.timestampMs || left.frame - right.frame);
|
|
249
|
+
return Object.freeze({
|
|
250
|
+
metadata: Object.freeze({
|
|
251
|
+
...recording.metadata,
|
|
252
|
+
evidence: replayEvidence()
|
|
253
|
+
}),
|
|
254
|
+
events: Object.freeze(events.map((event) => Object.freeze({ ...event })))
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function replayEvidence(): InputRecordingMetadata["evidence"] {
|
|
259
|
+
return {
|
|
260
|
+
oldCodebasePort: true,
|
|
261
|
+
recording: true,
|
|
262
|
+
playback: true,
|
|
263
|
+
seek: true,
|
|
264
|
+
looping: true
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function finite(value: unknown, fallback: number): number {
|
|
269
|
+
const number = Number(value);
|
|
270
|
+
return Number.isFinite(number) ? number : fallback;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function clamp(value: number, min: number, max: number): number {
|
|
274
|
+
return Math.max(min, Math.min(max, value));
|
|
275
|
+
}
|