Skip to content

Commit 673c9e3

Browse files
committed
Add Input Mapping V2 haptics and shortcut suppression - PR_26140_104-add-input-mapping-v2-haptics-and-shortcut-suppression
1 parent 7796e43 commit 673c9e3

12 files changed

Lines changed: 680 additions & 93 deletions

File tree

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# Input Mapping V2 Haptics + Shortcut Suppression Report
2+
3+
PR: PR_26140_104-add-input-mapping-v2-haptics-and-shortcut-suppression
4+
5+
## Source of Truth
6+
7+
- Read `docs/dev/PROJECT_INSTRUCTIONS.md` before implementation.
8+
- Active `docs/pr/BUILD_PR.md` is an unrelated Level 18 rebase note, so this report follows the explicit PR104 user request as the current BUILD scope.
9+
- One PR purpose: add engine-backed game controller haptics support and scoped Input Mapping V2 shortcut suppression.
10+
11+
## Changes
12+
13+
- Added `src/engine/input/GamepadHapticsService.js` as a reusable engine input service for game controller haptics.
14+
- Uses browser Gamepad API haptics only when available:
15+
- `gamepad.vibrationActuator.playEffect("dual-rumble", ...)`.
16+
- `gamepad.hapticActuators[].pulse(...)`.
17+
- `gamepad.hapticActuators[].playEffect(...)` when present.
18+
- Does not fake rumble when unsupported; unsupported, unavailable, or blocked haptics return actionable WARN messages.
19+
- Wired Input Mapping V2 through `EngineInputSourceService` so the tool consumes the engine haptics service rather than owning local haptics runtime logic.
20+
- Moved haptics UI into the Game Controller device section:
21+
- support state per detected controller,
22+
- selected-action `Enable Rumble` checkbox,
23+
- strength and duration controls,
24+
- `Test Rumble` button only for supported controllers.
25+
- Added scoped `Suppress Shortcuts` checkbox in the Capture accordion header.
26+
- Suppression is scoped to the Input Mapping V2 workspace root only:
27+
- Ctrl/Meta + Wheel is prevented inside the tool area when enabled,
28+
- Alt/Ctrl/Meta key browser/page shortcuts are suppressed inside the tool area when enabled where the browser permits,
29+
- shortcuts are not suppressed when the checkbox is disabled,
30+
- behavior outside the tool area is not suppressed.
31+
- Preserved `Disable Context` behavior.
32+
- Preserved capture, combo, gesture, tile selection, and selected-input highlighting behavior.
33+
- Adjusted combo polling so held game controller input repeats do not overwrite the first captured combo prompt with a duplicate-input warning while waiting for the second input.
34+
35+
## ToolState / Schema
36+
37+
- No schemas changed.
38+
- No sample JSON changed.
39+
- Haptics settings are kept UI-local per selected action because the existing Input Mapping V2 tool-local mapping/toolState contract does not expose a compatible action-options field for persisting rumble settings without schema drift.
40+
41+
## Playwright Impact
42+
43+
Playwright impacted: Yes.
44+
45+
Focused Workspace V2 coverage now validates:
46+
47+
- haptics support state is displayed for mocked gamepads,
48+
- `Test Rumble` calls a mocked supported actuator,
49+
- unsupported haptics logs an actionable WARN,
50+
- `Enable Rumble` can be toggled for the selected mapping/action,
51+
- shortcut suppression checkbox appears,
52+
- Ctrl + Wheel is prevented inside Input Mapping V2 when enabled,
53+
- Alt key suppression is applied inside Input Mapping V2 when enabled,
54+
- shortcuts are not suppressed when the checkbox is disabled,
55+
- `Disable Context` still works.
56+
57+
## Validation
58+
59+
Passed:
60+
61+
- `node --check src/engine/input/GamepadHapticsService.js`
62+
- `node --check tools/input-mapping-v2/js/services/EngineInputSourceService.js`
63+
- `node --check tools/input-mapping-v2/js/ToolStarterApp.js`
64+
- `node --check tools/input-mapping-v2/js/bootstrap.js`
65+
- `node --check tools/input-mapping-v2/js/controls/CaptureControl.js`
66+
- `node --check tools/input-mapping-v2/js/controls/DeviceListControl.js`
67+
- `node --check tests/input/GamepadHapticsService.test.mjs`
68+
- `node --check tests/run-tests.mjs`
69+
- `node --check tests/playwright/tools/WorkspaceManagerV2.spec.mjs`
70+
- `node -e "const module = await import('./src/engine/input/GamepadHapticsService.js'); if (typeof module.default !== 'function') throw new Error('GamepadHapticsService default export missing');"`
71+
- `node -e "const test = await import('./tests/input/GamepadHapticsService.test.mjs'); await test.run();"`
72+
- `npx playwright test tests/playwright/tools/WorkspaceManagerV2.spec.mjs -g "Input Mapping V2" --project=playwright --workers=1 --reporter=list` - 2 passed.
73+
- `npm run test:workspace-v2` - 61 passed.
74+
- `git diff --check` - passed; Git reported LF-to-CRLF working-copy warnings only.
75+
- `git status --short -- samples games` - no sample/game JSON changes.
76+
- `rg "imageDataUrl" tools/input-mapping-v2 src/engine/input tests/input tests/playwright/tools/WorkspaceManagerV2.spec.mjs` - no matches.
77+
78+
Notes:
79+
80+
- An initial full `npm run test:workspace-v2` run reached 60/61 and exposed a duplicate held-gamepad combo polling issue in the updated Input Mapping V2 test path. The app was fixed to ignore silent duplicate gamepad repeats during combo polling, the focused Input Mapping V2 slice passed, and the full suite was rerun successfully with 61/61 passing.
81+
- Full samples smoke test was skipped by explicit PR instruction.
82+
- Playwright V8 coverage was produced at `docs/dev/reports/playwright_v8_coverage_report.txt`; `docs/dev/reports/coverage_changed_js_guardrail.txt` reports no changed runtime JS coverage warnings.
83+
84+
## Manual Validation
85+
86+
1. Open Input Mapping V2 in Workspace V2.
87+
2. Connect or mock a game controller with haptics support.
88+
3. Confirm the Game Controller device section shows haptics support state and a `Test Rumble` button for supported controllers only.
89+
4. Select an action tile, enable `Enable Rumble`, adjust strength/duration, and click `Test Rumble`.
90+
5. Confirm unsupported controllers log a WARN instead of faking rumble.
91+
6. Enable `Suppress Shortcuts` and verify Ctrl + Wheel does not zoom the page while the pointer is inside Input Mapping V2.
92+
7. Verify right-click context suppression still follows only the separate `Disable Context` checkbox.
93+
8. Disable `Suppress Shortcuts` and confirm browser/page shortcuts behave normally again.
94+
95+
## Files Changed
96+
97+
- `src/engine/input/GamepadHapticsService.js`
98+
- `tests/input/GamepadHapticsService.test.mjs`
99+
- `tests/run-tests.mjs`
100+
- `tests/playwright/tools/WorkspaceManagerV2.spec.mjs`
101+
- `tools/input-mapping-v2/index.html`
102+
- `tools/input-mapping-v2/js/ToolStarterApp.js`
103+
- `tools/input-mapping-v2/js/bootstrap.js`
104+
- `tools/input-mapping-v2/js/controls/CaptureControl.js`
105+
- `tools/input-mapping-v2/js/controls/DeviceListControl.js`
106+
- `tools/input-mapping-v2/js/services/EngineInputSourceService.js`
107+
- `tools/input-mapping-v2/styles/inputMappingV2.css`
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/*
2+
Toolbox Aid
3+
David Quesenberry
4+
05/22/2026
5+
GamepadHapticsService.js
6+
*/
7+
8+
const DEFAULT_DURATION_MS = 80;
9+
const DEFAULT_STRONG_MAGNITUDE = 0.25;
10+
const DEFAULT_WEAK_MAGNITUDE = 0.15;
11+
12+
export default class GamepadHapticsService {
13+
constructor({ getGamepads = defaultGetGamepads } = {}) {
14+
this.getGamepads = getGamepads;
15+
}
16+
17+
getStatusReport() {
18+
const read = this.readGamepads();
19+
if (!read.ok) {
20+
return {
21+
apiAvailable: false,
22+
gamepads: [],
23+
warning: read.message,
24+
};
25+
}
26+
27+
return {
28+
apiAvailable: true,
29+
gamepads: read.gamepads.map((gamepad) => this.createStatus(gamepad)),
30+
warning: '',
31+
};
32+
}
33+
34+
async testRumble(gamepadIndex, options = {}) {
35+
const read = this.readGamepads();
36+
if (!read.ok) {
37+
return {
38+
ok: false,
39+
message: `Gamepad rumble unavailable: ${read.message}`,
40+
};
41+
}
42+
43+
const selectedIndex = Number(gamepadIndex);
44+
const gamepad = read.gamepads.find((candidate) => Number(candidate?.index) === selectedIndex);
45+
if (!gamepad) {
46+
return {
47+
ok: false,
48+
message: `Gamepad rumble unavailable: Gamepad ${selectedIndex} is not connected. Click inside this page, press the controller, and try again.`,
49+
};
50+
}
51+
52+
const actuator = findHapticActuator(gamepad);
53+
if (!actuator) {
54+
return {
55+
ok: false,
56+
message: `Gamepad rumble unavailable: ${gamepadLabel(gamepad)} does not expose GamepadHapticActuator, hapticActuators, or vibrationActuator. Use a browser and controller with haptics support.`,
57+
};
58+
}
59+
60+
const settings = normalizeRumbleSettings(options);
61+
try {
62+
await playHaptics(actuator, settings);
63+
return {
64+
ok: true,
65+
message: `Test rumble sent to ${gamepadLabel(gamepad)}.`,
66+
};
67+
} catch (error) {
68+
return {
69+
ok: false,
70+
message: `Gamepad rumble unavailable: haptic actuator request failed for ${gamepadLabel(gamepad)} (${error?.message || 'unknown error'}). Try a compatible controller with browser haptics support.`,
71+
};
72+
}
73+
}
74+
75+
readGamepads() {
76+
if (typeof this.getGamepads !== 'function') {
77+
return {
78+
ok: false,
79+
gamepads: [],
80+
message: 'Gamepad API provider is not available.',
81+
};
82+
}
83+
84+
try {
85+
return {
86+
ok: true,
87+
gamepads: Array.from(this.getGamepads() || []).filter((gamepad) => Boolean(gamepad && gamepad.connected !== false)),
88+
message: '',
89+
};
90+
} catch (error) {
91+
return {
92+
ok: false,
93+
gamepads: [],
94+
message: `navigator.getGamepads() failed (${error?.message || 'unknown error'}). Click inside this page, reconnect the controller, and try again.`,
95+
};
96+
}
97+
}
98+
99+
createStatus(gamepad) {
100+
const actuator = findHapticActuator(gamepad);
101+
return {
102+
actuatorType: actuator?.type ?? '',
103+
id: String(gamepad?.id || ''),
104+
index: Number.isInteger(Number(gamepad?.index)) ? Number(gamepad.index) : 0,
105+
label: gamepadLabel(gamepad),
106+
supported: Boolean(actuator),
107+
};
108+
}
109+
}
110+
111+
function defaultGetGamepads() {
112+
if (typeof navigator === 'undefined' || typeof navigator.getGamepads !== 'function') {
113+
return [];
114+
}
115+
return navigator.getGamepads() ?? [];
116+
}
117+
118+
function findHapticActuator(gamepad) {
119+
const vibrationActuator = gamepad?.vibrationActuator;
120+
if (vibrationActuator && typeof vibrationActuator.playEffect === 'function') {
121+
return {
122+
actuator: vibrationActuator,
123+
mode: 'playEffect',
124+
type: 'vibrationActuator',
125+
};
126+
}
127+
128+
const hapticActuator = Array.isArray(gamepad?.hapticActuators)
129+
? gamepad.hapticActuators.find((actuator) => typeof actuator?.pulse === 'function' || typeof actuator?.playEffect === 'function')
130+
: null;
131+
if (!hapticActuator) {
132+
return null;
133+
}
134+
135+
return {
136+
actuator: hapticActuator,
137+
mode: typeof hapticActuator.playEffect === 'function' ? 'playEffect' : 'pulse',
138+
type: 'hapticActuators',
139+
};
140+
}
141+
142+
async function playHaptics(hapticActuator, settings) {
143+
if (hapticActuator.mode === 'playEffect') {
144+
await hapticActuator.actuator.playEffect('dual-rumble', {
145+
duration: settings.durationMs,
146+
strongMagnitude: settings.strongMagnitude,
147+
weakMagnitude: settings.weakMagnitude,
148+
});
149+
return;
150+
}
151+
152+
await hapticActuator.actuator.pulse(Math.max(settings.strongMagnitude, settings.weakMagnitude), settings.durationMs);
153+
}
154+
155+
function normalizeRumbleSettings(options) {
156+
const strength = clampNumber(options.strength, DEFAULT_STRONG_MAGNITUDE, 0, 1);
157+
return {
158+
durationMs: Math.round(clampNumber(options.durationMs, DEFAULT_DURATION_MS, 20, 2000)),
159+
strongMagnitude: strength,
160+
weakMagnitude: clampNumber(options.weakMagnitude, Math.min(strength, DEFAULT_WEAK_MAGNITUDE), 0, 1),
161+
};
162+
}
163+
164+
function clampNumber(value, fallback, minimum, maximum) {
165+
const number = Number(value);
166+
if (!Number.isFinite(number)) {
167+
return fallback;
168+
}
169+
return Math.max(minimum, Math.min(maximum, number));
170+
}
171+
172+
function gamepadLabel(gamepad) {
173+
const index = Number.isInteger(Number(gamepad?.index)) ? Number(gamepad.index) : 0;
174+
const id = String(gamepad?.id || '').trim();
175+
return `${id || 'Gamepad'} (Gamepad ${index})`;
176+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
Toolbox Aid
3+
David Quesenberry
4+
05/22/2026
5+
GamepadHapticsService.test.mjs
6+
*/
7+
8+
import GamepadHapticsService from '../../src/engine/input/GamepadHapticsService.js';
9+
10+
function assert(condition, message) {
11+
if (!condition) {
12+
throw new Error(message);
13+
}
14+
}
15+
16+
export async function run() {
17+
const calls = [];
18+
const service = new GamepadHapticsService({
19+
getGamepads: () => [
20+
{
21+
connected: true,
22+
id: 'Supported Vibration Controller',
23+
index: 0,
24+
vibrationActuator: {
25+
playEffect: async (effect, parameters) => {
26+
calls.push({ effect, parameters, type: 'vibrationActuator' });
27+
},
28+
},
29+
},
30+
{
31+
connected: true,
32+
hapticActuators: [{
33+
pulse: async (value, duration) => {
34+
calls.push({ duration, type: 'hapticActuators', value });
35+
},
36+
}],
37+
id: 'Supported Pulse Controller',
38+
index: 1,
39+
},
40+
{
41+
connected: true,
42+
id: 'No Haptics Controller',
43+
index: 2,
44+
},
45+
],
46+
});
47+
48+
const status = service.getStatusReport();
49+
assert(status.gamepads[0].supported === true, 'vibrationActuator gamepad should report haptics support.');
50+
assert(status.gamepads[0].actuatorType === 'vibrationActuator', 'vibrationActuator status should identify the actuator source.');
51+
assert(status.gamepads[1].supported === true, 'hapticActuators gamepad should report haptics support.');
52+
assert(status.gamepads[1].actuatorType === 'hapticActuators', 'hapticActuators status should identify the actuator source.');
53+
assert(status.gamepads[2].supported === false, 'Gamepad without haptic actuators should report unsupported haptics.');
54+
55+
const vibrationResult = await service.testRumble(0, { durationMs: 120, strength: 0.7 });
56+
assert(vibrationResult.ok === true, 'vibrationActuator test rumble should succeed.');
57+
assert(calls[0].effect === 'dual-rumble', 'vibrationActuator should use dual-rumble.');
58+
assert(calls[0].parameters.duration === 120, 'vibrationActuator should receive duration.');
59+
assert(calls[0].parameters.strongMagnitude === 0.7, 'vibrationActuator should receive strong magnitude.');
60+
61+
const pulseResult = await service.testRumble(1, { durationMs: 90, strength: 0.4 });
62+
assert(pulseResult.ok === true, 'hapticActuators pulse test rumble should succeed.');
63+
assert(calls[1].type === 'hapticActuators', 'hapticActuators should use pulse when playEffect is unavailable.');
64+
assert(calls[1].duration === 90, 'pulse actuator should receive duration.');
65+
assert(calls[1].value === 0.4, 'pulse actuator should receive strength.');
66+
67+
const unsupportedResult = await service.testRumble(2);
68+
assert(unsupportedResult.ok === false, 'Unsupported haptics should not fake rumble.');
69+
assert(unsupportedResult.message.includes('does not expose'), 'Unsupported haptics should return an actionable message.');
70+
}

0 commit comments

Comments
 (0)