Files
SubMiner/src/renderer/handlers/controller-combo-tracker.ts
T
sudacode b31ddce627 feat(controller): add hold + press combos to main bindings (#328)
* feat(controller): add hold + press combos to main bindings

Main Alt+C actions can now be learned as combos such as L1 + D-pad right.
A combo fires on each press while its hold button is down, and claims its
press input so token move and popup scroll ignore it until it is released.
A plain action on a hold button fires on release, unless another bound
button was pressed during the hold.

The save path needed two changes for combos to persist. The IPC validator
rebuilt bindings field by field and dropped `hold`, so Alt+C showed a combo
from its local copy until the next reload turned it back into a plain
button. Validator and config resolver now share one parser. Saves also
replace the controller section, since the deep merge in patchRawConfig kept
the old `hold` when a combo was rebound to one button.

Also fixes Learn on the (D-pad) rows, which threw after cancel() cleared
the capture target.

* test(launcher): wait for content in background child test

The test polled for the fake app's output files to exist, but the shell
creates a redirect target before writing to it. A poll landing in that gap
read an empty env file and failed with '' !== '1', as seen once in CI.
Wait until both files have content instead.

* fix(controller): keep a diagonal push from learning as a combo

Learn took the first axis of a diagonal stick push as the hold and the
second as the press, saving a combo that one axis could never trigger. This
reached main Alt+C rows when they moved to combo capture. Axes crossing the
threshold in the same poll as the first input now count as one movement. A
second axis moved in a later poll still records a combo.

* fix(controller): only treat simultaneous axes as one movement

The diagonal push guard also blocked an axis activated in the same poll as a
button, so L1 plus a stick direction could not record the stick until it was
released and moved again. Apply the guard only when the first input is an
axis, so that input pair records as a combo again.
2026-11-05 23:23:54 -08:00

130 lines
5.0 KiB
TypeScript

import { controllerReviewInputSourceKey as inputKey } from '../../shared/controller-review';
import type { ControllerRawInput, ResolvedControllerDiscreteBinding } from '../../types';
import type { GamepadLike } from './gamepad-controller.js';
type BoundBinding = Exclude<ResolvedControllerDiscreteBinding, { kind: 'none' }>;
const isBound = (binding: ResolvedControllerDiscreteBinding): binding is BoundBinding =>
binding.kind !== 'none';
/**
* Per-poll view of hold + press combos in the main controller bindings. Dialog layouts have
* their own matching in `controller-screen-layout.ts`; the rules for choosing a binding match.
*
* A press belongs to the binding that matches the hold buttons down when it starts, so
* pressing the same input again during one hold fires the combo again, and letting go of the
* hold first does not turn the press into its plain binding.
*/
export function createControllerComboTracker() {
let pressedLastPoll: ReadonlySet<string> = new Set();
let holdKeys: ReadonlySet<string> = new Set();
let heldHoldKeys: string[] = [];
let usedHolds: ReadonlySet<string> = new Set();
/** Holds used during their press, kept for the poll that sees them released. */
let cancelledTaps: ReadonlySet<string> = new Set();
const claimedInputs = new Map<string, ControllerRawInput>();
function update(
bindings: readonly ResolvedControllerDiscreteBinding[],
isPressed: (input: ControllerRawInput) => boolean,
): void {
const bound = bindings.filter(isBound);
const pressed = new Set<string>();
for (const binding of bound) {
for (const input of binding.hold ? [binding, binding.hold] : [binding]) {
if (isPressed(input)) pressed.add(inputKey(input));
}
}
holdKeys = new Set(bound.flatMap((binding) => (binding.hold ? [inputKey(binding.hold)] : [])));
heldHoldKeys = [...holdKeys].filter((key) => pressed.has(key));
const freshPresses = bound
.map(inputKey)
.filter((key) => pressed.has(key) && !pressedLastPoll.has(key));
const nextUsedHolds = new Set(
heldHoldKeys.filter(
(holdKey) => usedHolds.has(holdKey) || freshPresses.some((key) => key !== holdKey),
),
);
cancelledTaps = new Set([...usedHolds, ...nextUsedHolds]);
usedHolds = nextUsedHolds;
pressedLastPoll = pressed;
for (const key of claimedInputs.keys()) {
if (!pressed.has(key)) claimedInputs.delete(key);
}
for (const binding of bound) {
if (!binding.hold) continue;
const pressKey = inputKey(binding);
const holdKey = inputKey(binding.hold);
if (pressKey !== holdKey && pressed.has(pressKey) && pressed.has(holdKey)) {
claimedInputs.set(pressKey, binding);
}
}
}
return {
update,
reset(): void {
pressedLastPoll = new Set();
holdKeys = new Set();
heldHoldKeys = [];
usedHolds = new Set();
cancelledTaps = new Set();
claimedInputs.clear();
},
/**
* Whether a press of this binding counts right now: with one hold button down, only the
* binding that uses it; with none, only a plain binding. Two holds down match nothing.
*/
matches(binding: ResolvedControllerDiscreteBinding): boolean {
if (!isBound(binding)) return false;
const pressKey = inputKey(binding);
const others = heldHoldKeys.filter((key) => key !== pressKey);
if (others.length > 1) return false;
return others[0] === (binding.hold ? inputKey(binding.hold) : undefined);
},
/** A plain binding on a hold button waits for release, when a tap can be told from a hold. */
firesOnRelease(binding: ResolvedControllerDiscreteBinding): boolean {
return isBound(binding) && !binding.hold && holdKeys.has(inputKey(binding));
},
/** Another bound input was pressed during this hold, so its release is not a tap. */
isTapCancelled(binding: ResolvedControllerDiscreteBinding): boolean {
return isBound(binding) && cancelledTaps.has(inputKey(binding));
},
/**
* The pad with inputs claimed by a combo reading as released, for the stick actions and
* their D-pad fallbacks. A claim lasts until the input is released, so letting go of the
* hold first does not start moving the word selection.
*/
withoutClaimedInputs(gamepad: GamepadLike): GamepadLike {
if (claimedInputs.size === 0) return gamepad;
const axes = [...gamepad.axes];
const buttons = [...gamepad.buttons];
for (const input of claimedInputs.values()) {
if (input.kind === 'button') {
buttons[input.buttonIndex] = { value: 0, pressed: false, touched: false };
} else if ((axes[input.axisIndex] ?? 0) > 0 === (input.direction === 'positive')) {
axes[input.axisIndex] = 0;
}
}
// Native Gamepad fields are prototype getters, so object spread would drop them.
return {
id: gamepad.id,
index: gamepad.index,
connected: gamepad.connected,
mapping: gamepad.mapping,
timestamp: gamepad.timestamp,
axes,
buttons,
};
},
};
}