Skip to content

Commit 1944059

Browse files
committed
Move Input Mapping V2 combo and visual state to engine input SSoT - PR_26140_113-move-input-mapping-v2-combo-and-visual-state-to-engine
1 parent db6c589 commit 1944059

10 files changed

Lines changed: 599 additions & 205 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Input Mapping V2 Engine SSoT Combo Report
2+
3+
Task: PR_26140_113-move-input-mapping-v2-combo-and-visual-state-to-engine
4+
Date: 2026-05-23
5+
6+
## Summary
7+
- Added `src/engine/input/InputComboState.js` and wired it into `InputService` as the engine-owned combo capture/state service.
8+
- Moved combo capture and live action-active decoration out of the Input Mapping V2 preview/tool-local model and through `EngineInputSourceService` -> `InputService`.
9+
- Added engine game controller gesture descriptors for `Btn Press`, `Btn Hold`, and `Btn Release`.
10+
- Updated mapping tile visible text so game controller mappings render as `GD` while preserving full metadata/title/export payload labels.
11+
- Preserved release-specific gamepad capture by tracking `buttonsReleased` through `GamepadState` and `GamepadInputAdapter`; explicit Btn Press/Hold/Release mappings keep gesture-specific bindings.
12+
- Increased active gamepad polling cadence to make release capture observable during active capture sessions.
13+
14+
## Scope Notes
15+
- No schemas were changed.
16+
- No sample JSON files were touched.
17+
- Existing tool payload/export behavior was preserved; combo mappings still use existing per-binding engine metadata.
18+
- Full samples smoke test was intentionally not run per request.
19+
20+
## Changed Files
21+
- `src/engine/input/InputComboState.js`
22+
- `src/engine/input/InputService.js`
23+
- `src/engine/input/GamepadState.js`
24+
- `src/engine/input/GamepadInputAdapter.js`
25+
- `src/engine/input/InputCapabilityDescriptors.js`
26+
- `tools/input-mapping-v2/js/services/EngineInputSourceService.js`
27+
- `tools/input-mapping-v2/js/ToolStarterApp.js`
28+
- `tools/input-mapping-v2/js/controls/PreviewPanelControl.js`
29+
- `tests/playwright/tools/WorkspaceManagerV2.spec.mjs`
30+
31+
## Validation
32+
- PASS: targeted syntax/import validation with `node --check` for changed engine input, Input Mapping V2, and Playwright files.
33+
- PASS: `npx playwright test tests/playwright/tools/WorkspaceManagerV2.spec.mjs -g "Input Mapping V2"` -> 11 passed.
34+
- PASS: `npm run test:workspace-v2` -> 70 passed.
35+
- SKIPPED: full samples smoke test, per explicit instruction.
36+
37+
## Focused Coverage Added/Updated
38+
- Source guard that Input Mapping V2 imports/uses engine combo capabilities and no longer keeps `comboCaptureInputs` or local `captureCombo(inputs)` implementation.
39+
- Mapping tile display confirms `Game Controller` visible token text becomes `GD`.
40+
- Game Controller gestures include `Btn Press`, `Btn Hold`, and `Btn Release`.
41+
- `Btn Release` waits through press and commits on release with a release-specific binding.
42+
- Combo capture uses engine `InputService ComboState` and retains two-input commit behavior.
43+
44+
## Coverage Report
45+
- `docs/dev/reports/playwright_v8_coverage_report.txt` was regenerated by Playwright coverage hooks.
46+
- `docs/dev/reports/coverage_changed_js_guardrail.txt` reports advisory coverage only.
47+
- Advisory warning remains for `src/engine/input/GamepadInputAdapter.js` function coverage below 50%; all changed runtime files executed lines in the active Playwright run.

src/engine/input/GamepadInputAdapter.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export default class GamepadInputAdapter {
4646
axes: [...(pad.axes ?? [])],
4747
buttonsDown: [...(pad.buttonsDown ?? [])],
4848
buttonsPressed: [...(pad.buttonsPressed ?? [])],
49+
buttonsReleased: [...(pad.buttonsReleased ?? [])],
4950
leftStick: { x: leftX, y: leftY },
5051
rightStick: { x: rightX, y: rightY },
5152
dpad: {
@@ -66,6 +67,7 @@ export default class GamepadInputAdapter {
6667
rightShoulderPressed: this.isPressed(pad, 5),
6768
isDown: (buttonIndex) => this.isDown(pad, buttonIndex),
6869
isPressed: (buttonIndex) => this.isPressed(pad, buttonIndex),
70+
isReleased: (buttonIndex) => this.isReleased(pad, buttonIndex),
6971
};
7072
}
7173

@@ -107,6 +109,7 @@ export default class GamepadInputAdapter {
107109
axes: [],
108110
buttonsDown: [],
109111
buttonsPressed: [],
112+
buttonsReleased: [],
110113
leftStick: { x: 0, y: 0 },
111114
rightStick: { x: 0, y: 0 },
112115
dpad: { up: false, down: false, left: false, right: false },
@@ -122,6 +125,7 @@ export default class GamepadInputAdapter {
122125
rightShoulderPressed: false,
123126
isDown: () => false,
124127
isPressed: () => false,
128+
isReleased: () => false,
125129
};
126130
}
127131

@@ -156,4 +160,8 @@ export default class GamepadInputAdapter {
156160
isPressed(pad, buttonIndex) {
157161
return Boolean(pad?.isPressed?.(buttonIndex));
158162
}
163+
164+
isReleased(pad, buttonIndex) {
165+
return Boolean(pad?.isReleased?.(buttonIndex));
166+
}
159167
}

src/engine/input/GamepadState.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ export default class GamepadState {
8282
const wasDown = previous.buttonsDown[index] ?? false;
8383
return isDown && !wasDown;
8484
});
85+
const buttonsReleased = current.buttonsDown.map((isDown, index) => {
86+
const wasDown = previous.buttonsDown[index] ?? false;
87+
return !isDown && wasDown;
88+
});
8589

8690
const snapshot = {
8791
index: current.index,
@@ -92,12 +96,16 @@ export default class GamepadState {
9296
axes: [...current.axes],
9397
buttonsDown: [...current.buttonsDown],
9498
buttonsPressed,
99+
buttonsReleased,
95100
isDown(buttonIndex) {
96101
return Boolean(this.buttonsDown[buttonIndex]);
97102
},
98103
isPressed(buttonIndex) {
99104
return Boolean(this.buttonsPressed[buttonIndex]);
100105
},
106+
isReleased(buttonIndex) {
107+
return Boolean(this.buttonsReleased[buttonIndex]);
108+
},
101109
};
102110

103111
return snapshot;

src/engine/input/InputCapabilityDescriptors.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ const GESTURE_DEFINITIONS = Object.freeze([
7979
wheelGesture('MouseWheelRight', 'Wheel Right', 'Mouse Wheel Right. Use for horizontal scrolling or cycling selections.'),
8080
comboGesture('MouseCombo', 'Mouse', ['mouse'], 'Mouse Combo. Use for combinations such as Shift + Mouse Right Button. Capture keyboard, mouse, wheel, or controller inputs for one selected action.'),
8181
gameControllerGesture('GameControllerButton', 'Button', 'Game controller button. Use for face buttons, shoulder buttons, and digital controller controls.', 'gameController'),
82+
gameControllerGesture('GameControllerButtonPress', 'Btn Press', 'Game controller button press. Use when an action should trigger on the transition from up to down.', 'gameController'),
83+
gameControllerGesture('GameControllerButtonHold', 'Btn Hold', 'Game controller button hold. Use while a game controller button remains held.', 'gameController'),
84+
gameControllerGesture('GameControllerButtonRelease', 'Btn Release', 'Game controller button release. Use when an action should trigger after a held button is released.', 'gameController'),
8285
gameControllerGesture('GameControllerTrigger', 'Trigger', 'Game controller trigger. Use for analog trigger actions such as accelerate, brake, or charge.', 'gameController'),
8386
gameControllerGesture('GameControllerStick', 'Stick', 'Game controller stick. Use for analog movement, aiming, or steering.', 'gameController'),
8487
gameControllerGesture('GameControllerDPad', 'DPad', 'Game controller DPad. Use for directional menu or movement actions.', 'gameController'),
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/*
2+
Toolbox Aid
3+
David Quesenberry
4+
05/23/2026
5+
InputComboState.js
6+
*/
7+
8+
export default class InputComboState {
9+
constructor({ requiredInputCount = 2 } = {}) {
10+
this.requiredInputCount = Math.max(2, Number(requiredInputCount) || 2);
11+
this.reset();
12+
}
13+
14+
begin({ actionLabel = 'selected action', deviceLabel = 'Combo' } = {}) {
15+
this.active = true;
16+
this.actionLabel = String(actionLabel || 'selected action');
17+
this.deviceLabel = String(deviceLabel || 'Combo');
18+
this.inputs = [];
19+
return {
20+
ok: true,
21+
waiting: true,
22+
message: `Combo capture: press any two keyboard, mouse, wheel, or game controller inputs for ${this.actionLabel}.`,
23+
engine: 'InputService ComboState'
24+
};
25+
}
26+
27+
reset() {
28+
this.active = false;
29+
this.actionLabel = '';
30+
this.deviceLabel = 'Combo';
31+
this.inputs = [];
32+
}
33+
34+
record(input, { silentDuplicate = false } = {}) {
35+
if (!input || !input.binding) {
36+
return {
37+
ok: false,
38+
message: 'Combo capture requires a valid input from the engine input service.'
39+
};
40+
}
41+
if (!this.active) {
42+
this.begin();
43+
}
44+
if (this.inputs.some((candidate) => candidate.binding === input.binding)) {
45+
return {
46+
ok: false,
47+
duplicate: true,
48+
silent: silentDuplicate === true,
49+
message: 'Combo capture needs two different inputs.'
50+
};
51+
}
52+
53+
this.inputs.push({ ...input });
54+
if (this.inputs.length < this.requiredInputCount) {
55+
return {
56+
ok: true,
57+
waiting: true,
58+
count: this.inputs.length,
59+
message: `Combo capture recorded ${comboCaptureInputLabel(input)}. Waiting for second input.`
60+
};
61+
}
62+
63+
const comboInput = this.createComboInput();
64+
this.active = false;
65+
return {
66+
ok: true,
67+
complete: true,
68+
count: this.inputs.length,
69+
input: comboInput,
70+
message: `${comboInput.label} captured.`
71+
};
72+
}
73+
74+
createComboInput() {
75+
const parts = this.inputs.slice(0, this.requiredInputCount);
76+
const comboLabel = parts.map(comboInputLabel).join(' + ');
77+
return {
78+
source: parts.every((input) => input.source === parts[0].source) ? parts[0].source : 'keyboard',
79+
binding: `Combo:${parts.map((input) => input.binding).join('+')}`,
80+
displayLabelLines: ['Combo', comboLabel],
81+
label: `Combo ${comboLabel}`,
82+
title: parts.map((input) => input.title || input.label).join('\n+\n'),
83+
engine: 'InputService ComboState'
84+
};
85+
}
86+
87+
isBindingActive(binding, activeInputBindings) {
88+
const activeBindings = toBindingSet(activeInputBindings);
89+
const bindingText = String(binding || '');
90+
if (activeBindings.has(bindingText)) {
91+
return true;
92+
}
93+
if (!bindingText.startsWith('Combo:')) {
94+
return false;
95+
}
96+
const parts = bindingText.slice('Combo:'.length).split('+').filter(Boolean);
97+
return parts.length > 0 && parts.every((part) => (
98+
liveBindingCandidates(part).some((candidate) => activeBindings.has(candidate))
99+
));
100+
}
101+
102+
decorateActions(actions = [], activeInputBindings = new Set()) {
103+
return actions.map((action) => ({
104+
...action,
105+
inputs: (action.inputs || []).map((input) => ({
106+
...input,
107+
isActionActive: this.isBindingActive(input.binding, activeInputBindings)
108+
}))
109+
}));
110+
}
111+
}
112+
113+
function toBindingSet(activeInputBindings) {
114+
if (activeInputBindings instanceof Set) {
115+
return activeInputBindings;
116+
}
117+
if (Array.isArray(activeInputBindings)) {
118+
return new Set(activeInputBindings);
119+
}
120+
return new Set();
121+
}
122+
123+
function liveBindingCandidates(binding) {
124+
const bindingText = String(binding || '');
125+
const candidates = [bindingText];
126+
if (bindingText.startsWith('Pad')) {
127+
const [pad, control] = bindingText.split(':');
128+
if (pad && control) {
129+
candidates.push(`${pad}:${control}`);
130+
}
131+
return candidates;
132+
}
133+
const baseBinding = bindingText.split(':')[0];
134+
if (baseBinding && baseBinding !== bindingText) {
135+
candidates.push(baseBinding);
136+
}
137+
return candidates;
138+
}
139+
140+
function comboInputLabel(input) {
141+
if (input.source === 'keyboard') {
142+
return keyboardComboLabel(input.binding);
143+
}
144+
if (input.source === 'mouse') {
145+
return input.label.replace(/^Mouse\s+/, 'Mouse ');
146+
}
147+
if (input.source === 'gamepad') {
148+
return input.label.replace(/^Game Controller\s+/, 'Game Controller ');
149+
}
150+
return input.label;
151+
}
152+
153+
function comboCaptureInputLabel(input) {
154+
if (input.source === 'keyboard') {
155+
return `Keyboard ${input.binding}`;
156+
}
157+
return comboInputLabel(input);
158+
}
159+
160+
function keyboardComboLabel(binding) {
161+
const namedKeys = {
162+
AltLeft: 'Alt',
163+
AltRight: 'Alt',
164+
Backspace: 'Backspace',
165+
ControlLeft: 'Ctrl',
166+
ControlRight: 'Ctrl',
167+
Delete: 'Delete',
168+
Enter: 'Enter',
169+
Escape: 'Esc',
170+
MetaLeft: 'Meta',
171+
MetaRight: 'Meta',
172+
ShiftLeft: 'Shift',
173+
ShiftRight: 'Shift',
174+
Space: 'Space',
175+
Tab: 'Tab'
176+
};
177+
if (namedKeys[binding]) {
178+
return namedKeys[binding];
179+
}
180+
if (/^Key[A-Z]$/.test(binding)) {
181+
return binding.slice(3);
182+
}
183+
if (/^Digit[0-9]$/.test(binding)) {
184+
return binding.slice(5);
185+
}
186+
return binding;
187+
}

0 commit comments

Comments
 (0)