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.
This commit is contained in:
sudacode authored and GitHub committed 2026-11-05 23:23:54 -08:00
1 parent 79e5535823
commit b31ddce627
30 files changed
+1078 -295

No files matched your search

+64 -35
View File
@@ -6,6 +6,7 @@ import type {
ResolvedControllerDiscreteBinding,
} from '../../types';
import { resolveControllerConfigForGamepad } from '../controller-profile-config.js';
import { createControllerComboTracker } from './controller-combo-tracker.js';
type ControllerButtonState = {
value: number;
@@ -228,6 +229,7 @@ function syncHeldActionBlocked(
state.initialFired = true;
}
/** Whether the binding's press input is down. A combo's `hold` is checked by the combo tracker. */
export function resolveDiscreteBindingPressed(
gamepad: GamepadLike,
binding: ResolvedControllerDiscreteBinding,
@@ -295,8 +297,24 @@ const WATCHING_ACTIONS = [
'playNextSubtitle',
] as const;
/** Every action fired by a fresh press; `holdSecondarySub` follows the button instead. */
const PRESS_ACTIONS = [
'toggleKeyboardOnlyMode',
'toggleLookup',
'closeLookup',
'mineCard',
'quitMpv',
'toggleCharacterNavigation',
...POPUP_ONLY_ACTIONS,
...WATCHING_ACTIONS,
] as const;
export function createGamepadController(options: GamepadControllerOptions) {
let previousActions = new Map<string, boolean>();
const comboTracker = createControllerComboTracker();
/** Plain actions on a hold button, pressed and waiting to see whether they were a tap. */
const pendingTaps = new Set<string>();
const handledThisPoll = new Set<string>();
let selectionHold = createHoldState();
let jumpHold = createHoldState();
let activeGamepadId: string | null = null;
@@ -361,14 +379,38 @@ export function createGamepadController(options: GamepadControllerOptions) {
config: ResolvedControllerConfig,
action: () => void,
): void {
handledThisPoll.add(actionKey);
const isPressed = resolveDiscreteBindingPressed(activeGamepad, binding, config);
const wasPressed = previousActions.get(actionKey) ?? false;
previousActions.set(actionKey, isPressed);
if (!wasPressed && isPressed) {
if (isPressed === wasPressed) return;
if (!isPressed) {
if (pendingTaps.delete(actionKey) && !comboTracker.isTapCancelled(binding)) action();
return;
}
if (!comboTracker.matches(binding)) return;
if (comboTracker.firesOnRelease(binding)) {
pendingTaps.add(actionKey);
} else {
action();
}
}
// The action does not apply this poll. Its edge state keeps following the pad, so a release
// is not missed and a held button waits for a fresh press, and a pending tap is dropped.
function trackActionWithoutFiring(
actionKey: (typeof PRESS_ACTIONS)[number],
activeGamepad: GamepadLike,
config: ResolvedControllerConfig,
): void {
previousActions.set(
actionKey,
resolveDiscreteBindingPressed(activeGamepad, config.bindings[actionKey], config),
);
if (!handledThisPoll.has(actionKey)) pendingTaps.delete(actionKey);
}
function handleSelectionAxis(value: number, now: number, config: ResolvedControllerConfig): void {
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
if (Math.abs(value) < activationThreshold) {
@@ -410,28 +452,14 @@ export function createGamepadController(options: GamepadControllerOptions) {
config: ResolvedControllerConfig,
now: number,
): void {
const discreteActions = [
['toggleKeyboardOnlyMode', config.bindings.toggleKeyboardOnlyMode],
['toggleLookup', config.bindings.toggleLookup],
['closeLookup', config.bindings.closeLookup],
['mineCard', config.bindings.mineCard],
['quitMpv', config.bindings.quitMpv],
['previousAudio', config.bindings.previousAudio],
['nextAudio', config.bindings.nextAudio],
['playCurrentAudio', config.bindings.playCurrentAudio],
['previousEntry', config.bindings.previousEntry],
['nextEntry', config.bindings.nextEntry],
['toggleCharacterNavigation', config.bindings.toggleCharacterNavigation],
...WATCHING_ACTIONS.map((actionKey) => [actionKey, config.bindings[actionKey]] as const),
] as const;
for (const [actionKey, binding] of discreteActions) {
previousActions.set(actionKey, resolveDiscreteBindingPressed(activeGamepad, binding, config));
for (const actionKey of PRESS_ACTIONS) {
trackActionWithoutFiring(actionKey, activeGamepad, config);
}
const axisGamepad = comboTracker.withoutClaimedInputs(activeGamepad);
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
const selectionValue = resolveAxisBindingValue(
activeGamepad,
axisGamepad,
config.bindings.leftStickHorizontal,
config.triggerDeadzone,
activationThreshold,
@@ -442,7 +470,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
syncHeldActionBlocked(
jumpHold,
resolveAxisBindingValue(
activeGamepad,
axisGamepad,
config.bindings.rightStickVertical,
config.triggerDeadzone,
activationThreshold,
@@ -463,6 +491,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
const activeGamepad = resolveActiveGamepad(connectedGamepads, config);
const previousActiveGamepadId = activeGamepadId;
publishState(connectedGamepads, activeGamepad);
handledThisPoll.clear();
if (!activeGamepad) {
syncSecondarySubHold(false);
@@ -472,6 +501,8 @@ export function createGamepadController(options: GamepadControllerOptions) {
// the press that reveals it started after the screen opened, so it should count.
screenLayoutWaitingForPad = config.enabled && (options.getScreenLayout?.() ?? null) !== null;
previousActions = new Map();
comboTracker.reset();
pendingTaps.clear();
resetHeldAction(selectionHold);
resetHeldAction(jumpHold);
lastPollAt = null;
@@ -481,11 +512,17 @@ export function createGamepadController(options: GamepadControllerOptions) {
if (activeGamepad.id !== previousActiveGamepadId) {
activeScreenLayout = null;
previousActions = new Map();
comboTracker.reset();
pendingTaps.clear();
resetHeldAction(selectionHold);
resetHeldAction(jumpHold);
}
const activeConfig = resolveControllerConfigForGamepad(config, activeGamepad.id);
comboTracker.update(
[...PRESS_ACTIONS, 'holdSecondarySub' as const].map((key) => activeConfig.bindings[key]),
(input) => resolveDiscreteBindingPressed(activeGamepad, input, activeConfig),
);
// A screen layout or another window owning the controller pauses every normal action,
// including the keyboard-mode toggle and the secondary subtitle hold. Their edge state
@@ -521,7 +558,8 @@ export function createGamepadController(options: GamepadControllerOptions) {
activeGamepad,
activeConfig.bindings.holdSecondarySub,
activeConfig,
),
) &&
comboTracker.matches(activeConfig.bindings.holdSecondarySub),
);
// Pause and subtitle navigation also work while simply watching; a blocking modal pauses them.
@@ -592,6 +630,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
);
const activationThreshold = Math.max(activeConfig.stickDeadzone, 0.55);
const axisGamepad = comboTracker.withoutClaimedInputs(activeGamepad);
if (options.getLookupWindowOpen()) {
handleActionEdge(
@@ -631,7 +670,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
);
const primaryScroll = resolveAxisBindingValue(
activeGamepad,
axisGamepad,
activeConfig.bindings.leftStickVertical,
activeConfig.triggerDeadzone,
activeConfig.stickDeadzone,
@@ -644,7 +683,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
handleJumpAxis(
resolveAxisBindingValue(
activeGamepad,
axisGamepad,
activeConfig.bindings.rightStickVertical,
activeConfig.triggerDeadzone,
activationThreshold,
@@ -654,24 +693,14 @@ export function createGamepadController(options: GamepadControllerOptions) {
);
} else {
resetHeldAction(jumpHold);
// Popup-only buttons keep tracking the pad while the popup is closed, like blocked
// actions do: a release is not missed, and a button held as the popup opens waits for
// a fresh press instead of firing at once.
for (const actionKey of POPUP_ONLY_ACTIONS) {
previousActions.set(
actionKey,
resolveDiscreteBindingPressed(
activeGamepad,
activeConfig.bindings[actionKey],
activeConfig,
),
);
trackActionWithoutFiring(actionKey, activeGamepad, activeConfig);
}
}
handleSelectionAxis(
resolveAxisBindingValue(
activeGamepad,
axisGamepad,
activeConfig.bindings.leftStickHorizontal,
activeConfig.triggerDeadzone,
activationThreshold,