feat(controller): add inline remap modal with descriptor-based bindings (#21)

This commit is contained in:
2026-03-15 15:55:45 -07:00
committed by GitHub
parent 9eed37420e
commit 478869ff28
38 changed files with 3136 additions and 1431 deletions

View File

@@ -0,0 +1,129 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createControllerBindingCapture } from './controller-binding-capture.js';
function createSnapshot(
overrides: {
axes?: number[];
buttons?: Array<{ value: number; pressed?: boolean; touched?: boolean }>;
} = {},
) {
return {
axes: overrides.axes ?? [0, 0, 0, 0, 0],
buttons:
overrides.buttons ??
Array.from({ length: 12 }, () => ({
value: 0,
pressed: false,
touched: false,
})),
};
}
test('controller binding capture waits for neutral-to-active button edge', () => {
const capture = createControllerBindingCapture({
triggerDeadzone: 0.5,
stickDeadzone: 0.2,
});
const heldButtons = createSnapshot({
buttons: [{ value: 1, pressed: true, touched: true }],
});
capture.arm({ actionId: 'toggleLookup', bindingType: 'discrete' }, heldButtons);
assert.equal(capture.poll(heldButtons), null);
const neutralButtons = createSnapshot();
assert.equal(capture.poll(neutralButtons), null);
const freshPress = createSnapshot({
buttons: [{ value: 1, pressed: true, touched: true }],
});
assert.deepEqual(capture.poll(freshPress), {
actionId: 'toggleLookup',
bindingType: 'discrete',
binding: { kind: 'button', buttonIndex: 0 },
});
});
test('controller binding capture records fresh axis direction for discrete learn mode', () => {
const capture = createControllerBindingCapture({
triggerDeadzone: 0.5,
stickDeadzone: 0.2,
});
capture.arm({ actionId: 'closeLookup', bindingType: 'discrete' }, createSnapshot());
assert.deepEqual(capture.poll(createSnapshot({ axes: [0, 0, 0, -0.8] })), {
actionId: 'closeLookup',
bindingType: 'discrete',
binding: { kind: 'axis', axisIndex: 3, direction: 'negative' },
});
});
test('controller binding capture ignores analog drift inside deadzone', () => {
const capture = createControllerBindingCapture({
triggerDeadzone: 0.5,
stickDeadzone: 0.3,
});
capture.arm({ actionId: 'mineCard', bindingType: 'discrete' }, createSnapshot());
assert.equal(capture.poll(createSnapshot({ axes: [0.2, 0, 0, 0] })), null);
assert.equal(capture.isArmed(), true);
});
test('controller binding capture emits axis binding for continuous learn mode', () => {
const capture = createControllerBindingCapture({
triggerDeadzone: 0.5,
stickDeadzone: 0.2,
});
capture.arm(
{
actionId: 'leftStickHorizontal',
bindingType: 'axis',
dpadFallback: 'horizontal',
},
createSnapshot(),
);
assert.deepEqual(capture.poll(createSnapshot({ axes: [0, 0, 0, 0.9] })), {
actionId: 'leftStickHorizontal',
bindingType: 'axis',
binding: { kind: 'axis', axisIndex: 3, dpadFallback: 'horizontal' },
});
});
test('controller binding capture ignores button presses for continuous learn mode', () => {
const capture = createControllerBindingCapture({
triggerDeadzone: 0.5,
stickDeadzone: 0.2,
});
capture.arm(
{
actionId: 'leftStickHorizontal',
bindingType: 'axis',
dpadFallback: 'horizontal',
},
createSnapshot(),
);
assert.equal(
capture.poll(
createSnapshot({
buttons: [{ value: 1, pressed: true, touched: true }],
}),
),
null,
);
assert.deepEqual(capture.poll(createSnapshot({ axes: [0, 0, 0.75, 0, 0] })), {
actionId: 'leftStickHorizontal',
bindingType: 'axis',
binding: { kind: 'axis', axisIndex: 2, dpadFallback: 'horizontal' },
});
});

View File

@@ -0,0 +1,194 @@
import type {
ControllerDpadFallback,
ResolvedControllerAxisBinding,
ResolvedControllerDiscreteBinding,
} from '../../types';
type ControllerButtonState = {
value: number;
pressed?: boolean;
touched?: boolean;
};
type ControllerBindingCaptureSnapshot = {
axes: readonly number[];
buttons: readonly ControllerButtonState[];
};
type ControllerBindingCaptureTarget =
| {
actionId: string;
bindingType: 'discrete';
}
| {
actionId: string;
bindingType: 'axis';
dpadFallback: ControllerDpadFallback;
}
| {
actionId: string;
bindingType: 'dpad';
};
type ControllerBindingCaptureResult =
| {
actionId: string;
bindingType: 'discrete';
binding: ResolvedControllerDiscreteBinding;
}
| {
actionId: string;
bindingType: 'axis';
binding: ResolvedControllerAxisBinding;
}
| {
actionId: string;
bindingType: 'dpad';
dpadDirection: ControllerDpadFallback;
};
function isActiveButton(button: ControllerButtonState | undefined, triggerDeadzone: number): boolean {
if (!button) return false;
return Boolean(button.pressed) || button.value >= triggerDeadzone;
}
function getAxisDirection(
value: number | undefined,
activationThreshold: number,
): 'negative' | 'positive' | null {
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
if (Math.abs(value) < activationThreshold) return null;
return value > 0 ? 'positive' : 'negative';
}
const DPAD_BUTTON_INDICES = [12, 13, 14, 15] as const;
export function createControllerBindingCapture(options: {
triggerDeadzone: number;
stickDeadzone: number;
}) {
let target: ControllerBindingCaptureTarget | null = null;
const blockedButtons = new Set<number>();
const blockedAxisDirections = new Set<string>();
function resetBlockedState(snapshot: ControllerBindingCaptureSnapshot): void {
blockedButtons.clear();
blockedAxisDirections.clear();
snapshot.buttons.forEach((button, index) => {
if (isActiveButton(button, options.triggerDeadzone)) {
blockedButtons.add(index);
}
});
const activationThreshold = Math.max(options.stickDeadzone, 0.55);
snapshot.axes.forEach((value, index) => {
const direction = getAxisDirection(value, activationThreshold);
if (direction) {
blockedAxisDirections.add(`${index}:${direction}`);
}
});
}
function arm(nextTarget: ControllerBindingCaptureTarget, snapshot: ControllerBindingCaptureSnapshot): void {
target = nextTarget;
resetBlockedState(snapshot);
}
function cancel(): void {
target = null;
blockedButtons.clear();
blockedAxisDirections.clear();
}
function poll(snapshot: ControllerBindingCaptureSnapshot): ControllerBindingCaptureResult | null {
if (!target) return null;
snapshot.buttons.forEach((button, index) => {
if (!isActiveButton(button, options.triggerDeadzone)) {
blockedButtons.delete(index);
}
});
const activationThreshold = Math.max(options.stickDeadzone, 0.55);
snapshot.axes.forEach((value, index) => {
const negativeKey = `${index}:negative`;
const positiveKey = `${index}:positive`;
if (getAxisDirection(value, activationThreshold) === null) {
blockedAxisDirections.delete(negativeKey);
blockedAxisDirections.delete(positiveKey);
}
});
// D-pad capture: only respond to d-pad buttons (12-15)
if (target.bindingType === 'dpad') {
for (const index of DPAD_BUTTON_INDICES) {
if (!isActiveButton(snapshot.buttons[index], options.triggerDeadzone)) continue;
if (blockedButtons.has(index)) continue;
const dpadDirection: ControllerDpadFallback =
index === 12 || index === 13 ? 'vertical' : 'horizontal';
cancel();
return {
actionId: target.actionId,
bindingType: 'dpad' as const,
dpadDirection,
};
}
return null;
}
// After dpad early-return, only 'discrete' | 'axis' remain
const narrowedTarget: Extract<ControllerBindingCaptureTarget, { bindingType: 'discrete' | 'axis' }> = target;
for (let index = 0; index < snapshot.buttons.length; index += 1) {
if (!isActiveButton(snapshot.buttons[index], options.triggerDeadzone)) continue;
if (blockedButtons.has(index)) continue;
if (narrowedTarget.bindingType === 'axis') continue;
const result: ControllerBindingCaptureResult = {
actionId: narrowedTarget.actionId,
bindingType: 'discrete',
binding: { kind: 'button', buttonIndex: index },
};
cancel();
return result;
}
for (let index = 0; index < snapshot.axes.length; index += 1) {
const direction = getAxisDirection(snapshot.axes[index], activationThreshold);
if (!direction) continue;
const directionKey = `${index}:${direction}`;
if (blockedAxisDirections.has(directionKey)) continue;
const result: ControllerBindingCaptureResult =
narrowedTarget.bindingType === 'discrete'
? {
actionId: narrowedTarget.actionId,
bindingType: 'discrete',
binding: { kind: 'axis', axisIndex: index, direction },
}
: {
actionId: narrowedTarget.actionId,
bindingType: 'axis',
binding: {
kind: 'axis',
axisIndex: index,
dpadFallback: narrowedTarget.dpadFallback,
},
};
cancel();
return result;
}
return null;
}
return {
arm,
cancel,
isArmed: (): boolean => target !== null,
getTargetActionId: (): string | null => target?.actionId ?? null,
poll,
};
}

View File

@@ -13,6 +13,20 @@ type TestGamepad = {
buttons: Array<{ value: number; pressed?: boolean; touched?: boolean }>;
};
const DEFAULT_BUTTON_INDICES = {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
} satisfies ResolvedControllerConfig['buttonIndices'];
function createGamepad(
id: string,
options: Partial<Pick<TestGamepad, 'index' | 'axes' | 'buttons'>> = {},
@@ -35,7 +49,7 @@ function createGamepad(
function createControllerConfig(
overrides: Omit<Partial<ResolvedControllerConfig>, 'bindings' | 'buttonIndices'> & {
bindings?: Partial<ResolvedControllerConfig['bindings']>;
bindings?: Partial<Record<keyof ResolvedControllerConfig['bindings'], unknown>>;
buttonIndices?: Partial<ResolvedControllerConfig['buttonIndices']>;
} = {},
): ResolvedControllerConfig {
@@ -57,39 +71,92 @@ function createControllerConfig(
repeatDelayMs: 320,
repeatIntervalMs: 120,
buttonIndices: {
select: 6,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
...DEFAULT_BUTTON_INDICES,
...(buttonIndexOverrides ?? {}),
},
bindings: {
toggleLookup: 'buttonSouth',
closeLookup: 'buttonEast',
toggleKeyboardOnlyMode: 'buttonNorth',
mineCard: 'buttonWest',
quitMpv: 'select',
previousAudio: 'none',
nextAudio: 'rightShoulder',
playCurrentAudio: 'leftShoulder',
toggleMpvPause: 'leftStickPress',
leftStickHorizontal: 'leftStickX',
leftStickVertical: 'leftStickY',
rightStickHorizontal: 'rightStickX',
rightStickVertical: 'rightStickY',
...(bindingOverrides ?? {}),
toggleLookup: { kind: 'button', buttonIndex: 0 },
closeLookup: { kind: 'button', buttonIndex: 1 },
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
mineCard: { kind: 'button', buttonIndex: 2 },
quitMpv: { kind: 'button', buttonIndex: 6 },
previousAudio: { kind: 'none' },
nextAudio: { kind: 'button', buttonIndex: 5 },
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
...normalizeBindingOverrides(bindingOverrides ?? {}, {
...DEFAULT_BUTTON_INDICES,
...(buttonIndexOverrides ?? {}),
}),
},
...restOverrides,
};
}
function normalizeBindingOverrides(
overrides: Partial<Record<keyof ResolvedControllerConfig['bindings'], unknown>>,
buttonIndices: ResolvedControllerConfig['buttonIndices'],
): Partial<ResolvedControllerConfig['bindings']> {
const legacyButtonIndices = {
select: buttonIndices.select,
buttonSouth: buttonIndices.buttonSouth,
buttonEast: buttonIndices.buttonEast,
buttonWest: buttonIndices.buttonWest,
buttonNorth: buttonIndices.buttonNorth,
leftShoulder: buttonIndices.leftShoulder,
rightShoulder: buttonIndices.rightShoulder,
leftStickPress: buttonIndices.leftStickPress,
rightStickPress: buttonIndices.rightStickPress,
leftTrigger: buttonIndices.leftTrigger,
rightTrigger: buttonIndices.rightTrigger,
} as const;
const legacyAxisIndices = {
leftStickX: 0,
leftStickY: 1,
rightStickX: 3,
rightStickY: 4,
} as const;
const axisFallbackByKey = {
leftStickHorizontal: 'horizontal',
leftStickVertical: 'vertical',
rightStickHorizontal: 'none',
rightStickVertical: 'none',
} as const;
const normalized: Partial<ResolvedControllerConfig['bindings']> = {};
for (const [key, value] of Object.entries(overrides) as Array<
[keyof ResolvedControllerConfig['bindings'], unknown]
>) {
if (typeof value === 'string') {
if (value === 'none') {
normalized[key] = { kind: 'none' } as never;
continue;
}
if (value in legacyButtonIndices) {
normalized[key] = {
kind: 'button',
buttonIndex: legacyButtonIndices[value as keyof typeof legacyButtonIndices],
} as never;
continue;
}
if (value in legacyAxisIndices) {
normalized[key] = {
kind: 'axis',
axisIndex: legacyAxisIndices[value as keyof typeof legacyAxisIndices],
dpadFallback: axisFallbackByKey[key as keyof typeof axisFallbackByKey] ?? 'none',
} as never;
continue;
}
}
normalized[key] = value as never;
}
return normalized;
}
test('gamepad controller selects the first connected controller by default', () => {
const updates: string[] = [];
const controller = createGamepadController({
@@ -184,6 +251,82 @@ test('gamepad controller allows keyboard-mode toggle while other actions stay ga
assert.deepEqual(calls, ['toggle-keyboard-mode']);
});
test('gamepad controller re-evaluates interaction gating after toggling keyboard mode', () => {
const calls: string[] = [];
let keyboardModeEnabled = true;
const buttons = Array.from({ length: 8 }, () => ({ value: 0, pressed: false, touched: false }));
buttons[0] = { value: 1, pressed: true, touched: true };
buttons[3] = { value: 1, pressed: true, touched: true };
const controller = createGamepadController({
getGamepads: () => [createGamepad('pad-1', { buttons })],
getConfig: () => createControllerConfig(),
getKeyboardModeEnabled: () => keyboardModeEnabled,
getLookupWindowOpen: () => false,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => {
calls.push('toggle-keyboard-mode');
keyboardModeEnabled = false;
},
toggleLookup: () => calls.push('toggle-lookup'),
closeLookup: () => {},
moveSelection: () => {},
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => {},
toggleMpvPause: () => {},
scrollPopup: () => {},
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
assert.deepEqual(calls, ['toggle-keyboard-mode']);
});
test('gamepad controller resets edge state when active controller changes', () => {
const calls: string[] = [];
let currentGamepads = [
createGamepad('pad-1', {
buttons: [{ value: 1, pressed: true, touched: true }],
}),
];
const controller = createGamepadController({
getGamepads: () => currentGamepads,
getConfig: () => createControllerConfig(),
getKeyboardModeEnabled: () => true,
getLookupWindowOpen: () => false,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => {},
toggleLookup: () => calls.push('toggle-lookup'),
closeLookup: () => {},
moveSelection: () => {},
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => {},
toggleMpvPause: () => {},
scrollPopup: () => {},
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
currentGamepads = [
createGamepad('pad-2', {
buttons: [{ value: 1, pressed: true, touched: true }],
}),
];
controller.poll(50);
assert.deepEqual(calls, ['toggle-lookup', 'toggle-lookup']);
});
test('gamepad controller does not toggle keyboard mode when controller support is disabled', () => {
const calls: string[] = [];
const buttons = Array.from({ length: 8 }, () => ({ value: 0, pressed: false, touched: false }));
@@ -622,6 +765,46 @@ test('gamepad controller trigger digital mode uses pressed state only', () => {
assert.deepEqual(calls, ['play-audio', 'toggle-mpv-pause']);
});
test('gamepad controller digital trigger bindings ignore analog-only trigger values', () => {
const calls: string[] = [];
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
buttons[6] = { value: 0.9, pressed: false, touched: true };
buttons[7] = { value: 0.9, pressed: false, touched: true };
const controller = createGamepadController({
getGamepads: () => [createGamepad('pad-1', { buttons })],
getConfig: () =>
createControllerConfig({
triggerInputMode: 'digital',
triggerDeadzone: 0.6,
bindings: {
playCurrentAudio: 'rightTrigger',
toggleMpvPause: 'leftTrigger',
},
}),
getKeyboardModeEnabled: () => true,
getLookupWindowOpen: () => true,
getInteractionBlocked: () => false,
toggleKeyboardMode: () => {},
toggleLookup: () => {},
closeLookup: () => {},
moveSelection: () => {},
mineCard: () => {},
quitMpv: () => {},
previousAudio: () => {},
nextAudio: () => {},
playCurrentAudio: () => calls.push('play-audio'),
toggleMpvPause: () => calls.push('toggle-mpv-pause'),
scrollPopup: () => {},
jumpPopup: () => {},
onState: () => {},
});
controller.poll(0);
assert.deepEqual(calls, []);
});
test('gamepad controller maps L3 to mpv pause and keeps unbound audio action inactive', () => {
const calls: string[] = [];
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));

View File

@@ -1,10 +1,9 @@
import type {
ControllerAxisBinding,
ControllerButtonBinding,
ControllerDeviceInfo,
ControllerRuntimeSnapshot,
ControllerTriggerInputMode,
ResolvedControllerAxisBinding,
ResolvedControllerConfig,
ResolvedControllerDiscreteBinding,
} from '../../types';
type ControllerButtonState = {
@@ -50,69 +49,18 @@ type HoldState = {
initialFired: boolean;
};
const DEFAULT_BUTTON_INDEX_BY_BINDING: Record<Exclude<ControllerButtonBinding, 'none'>, number> = {
select: 8,
buttonSouth: 0,
buttonEast: 1,
buttonWest: 2,
buttonNorth: 3,
leftShoulder: 4,
rightShoulder: 5,
leftStickPress: 9,
rightStickPress: 10,
leftTrigger: 6,
rightTrigger: 7,
};
const AXIS_INDEX_BY_BINDING: Record<ControllerAxisBinding, number> = {
leftStickX: 0,
leftStickY: 1,
rightStickX: 3,
rightStickY: 4,
};
const DPAD_BUTTON_INDEX = {
up: 12,
down: 13,
left: 14,
right: 15,
} as const;
const DPAD_AXIS_INDEX = {
horizontal: 6,
vertical: 7,
} as const;
function isTriggerBinding(binding: ControllerButtonBinding): boolean {
return binding === 'leftTrigger' || binding === 'rightTrigger';
}
function resolveButtonIndex(
config: ResolvedControllerConfig,
binding: ControllerButtonBinding,
): number {
if (binding === 'none') {
return -1;
}
return config.buttonIndices[binding] ?? DEFAULT_BUTTON_INDEX_BY_BINDING[binding];
}
function normalizeButtonState(
gamepad: GamepadLike,
config: ResolvedControllerConfig,
binding: ControllerButtonBinding,
triggerInputMode: ControllerTriggerInputMode,
triggerDeadzone: number,
): boolean {
if (binding === 'none') {
return false;
}
const button = gamepad.buttons[resolveButtonIndex(config, binding)];
if (isTriggerBinding(binding)) {
return normalizeTriggerState(button, triggerInputMode, triggerDeadzone);
}
return normalizeRawButtonState(button, triggerDeadzone);
}
function normalizeRawButtonState(
button: ControllerButtonState | undefined,
triggerDeadzone: number,
@@ -121,23 +69,18 @@ function normalizeRawButtonState(
return Boolean(button.pressed) || button.value >= triggerDeadzone;
}
function normalizeTriggerState(
function resolveTriggerBindingPressed(
button: ControllerButtonState | undefined,
mode: ControllerTriggerInputMode,
triggerDeadzone: number,
config: ResolvedControllerConfig,
): boolean {
if (!button) return false;
if (mode === 'digital') {
if (config.triggerInputMode === 'digital') {
return Boolean(button.pressed);
}
if (mode === 'analog') {
return button.value >= triggerDeadzone;
if (config.triggerInputMode === 'analog') {
return button.value >= config.triggerDeadzone;
}
return Boolean(button.pressed) || button.value >= triggerDeadzone;
}
function resolveAxisValue(gamepad: GamepadLike, binding: ControllerAxisBinding): number {
return gamepad.axes[AXIS_INDEX_BY_BINDING[binding]] ?? 0;
return normalizeRawButtonState(button, config.triggerDeadzone);
}
function resolveGamepadAxis(gamepad: GamepadLike, axisIndex: number): number {
@@ -251,8 +194,57 @@ function syncHeldActionBlocked(
state.initialFired = true;
}
function resolveDiscreteBindingPressed(
gamepad: GamepadLike,
binding: ResolvedControllerDiscreteBinding,
config: ResolvedControllerConfig,
): boolean {
if (binding.kind === 'none') {
return false;
}
if (binding.kind === 'button') {
const button = gamepad.buttons[binding.buttonIndex];
const isTriggerBinding =
binding.buttonIndex === config.buttonIndices.leftTrigger ||
binding.buttonIndex === config.buttonIndices.rightTrigger;
return isTriggerBinding
? resolveTriggerBindingPressed(button, config)
: normalizeRawButtonState(button, config.triggerDeadzone);
}
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
const axisValue = resolveGamepadAxis(gamepad, binding.axisIndex);
return binding.direction === 'positive'
? axisValue >= activationThreshold
: axisValue <= -activationThreshold;
}
function resolveAxisBindingValue(
gamepad: GamepadLike,
binding: ResolvedControllerAxisBinding,
triggerDeadzone: number,
activationThreshold: number,
): number {
if (binding.kind === 'none') {
return 0;
}
const axisValue = resolveGamepadAxis(gamepad, binding.axisIndex);
if (Math.abs(axisValue) >= activationThreshold) {
return axisValue;
}
if (binding.dpadFallback === 'horizontal') {
return resolveDpadHorizontalValue(gamepad, triggerDeadzone);
}
if (binding.dpadFallback === 'vertical') {
return resolveDpadVerticalValue(gamepad, triggerDeadzone);
}
return axisValue;
}
export function createGamepadController(options: GamepadControllerOptions) {
let previousButtons = new Map<ControllerButtonBinding, boolean>();
let previousActions = new Map<string, boolean>();
let selectionHold = createHoldState();
let jumpHold = createHoldState();
let activeGamepadId: string | null = null;
@@ -297,16 +289,16 @@ export function createGamepadController(options: GamepadControllerOptions) {
});
}
function handleButtonEdge(
binding: ControllerButtonBinding,
isPressed: boolean,
function handleActionEdge(
actionKey: string,
binding: ResolvedControllerDiscreteBinding,
activeGamepad: GamepadLike,
config: ResolvedControllerConfig,
action: () => void,
): void {
if (binding === 'none') {
return;
}
const wasPressed = previousButtons.get(binding) ?? false;
previousButtons.set(binding, isPressed);
const isPressed = resolveDiscreteBindingPressed(activeGamepad, binding, config);
const wasPressed = previousActions.get(actionKey) ?? false;
previousActions.set(actionKey, isPressed);
if (!wasPressed && isPressed) {
action();
}
@@ -353,47 +345,42 @@ export function createGamepadController(options: GamepadControllerOptions) {
config: ResolvedControllerConfig,
now: number,
): void {
const buttonBindings = new Set<ControllerButtonBinding>([
config.bindings.toggleKeyboardOnlyMode,
config.bindings.toggleLookup,
config.bindings.closeLookup,
config.bindings.mineCard,
config.bindings.quitMpv,
config.bindings.previousAudio,
config.bindings.nextAudio,
config.bindings.playCurrentAudio,
config.bindings.toggleMpvPause,
]);
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],
['toggleMpvPause', config.bindings.toggleMpvPause],
] as const;
for (const binding of buttonBindings) {
if (binding === 'none') continue;
previousButtons.set(
binding,
normalizeButtonState(
activeGamepad,
config,
binding,
config.triggerInputMode,
config.triggerDeadzone,
),
);
for (const [actionKey, binding] of discreteActions) {
previousActions.set(actionKey, resolveDiscreteBindingPressed(activeGamepad, binding, config));
}
const selectionValue = (() => {
const axisValue = resolveAxisValue(activeGamepad, config.bindings.leftStickHorizontal);
if (Math.abs(axisValue) >= Math.max(config.stickDeadzone, 0.55)) {
return axisValue;
}
return resolveDpadHorizontalValue(activeGamepad, config.triggerDeadzone);
})();
syncHeldActionBlocked(selectionHold, selectionValue, now, Math.max(config.stickDeadzone, 0.55));
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
const selectionValue = resolveAxisBindingValue(
activeGamepad,
config.bindings.leftStickHorizontal,
config.triggerDeadzone,
activationThreshold,
);
syncHeldActionBlocked(selectionHold, selectionValue, now, activationThreshold);
if (options.getLookupWindowOpen()) {
syncHeldActionBlocked(
jumpHold,
resolveAxisValue(activeGamepad, config.bindings.rightStickVertical),
resolveAxisBindingValue(
activeGamepad,
config.bindings.rightStickVertical,
config.triggerDeadzone,
activationThreshold,
),
now,
Math.max(config.stickDeadzone, 0.55),
activationThreshold,
);
} else {
resetHeldAction(jumpHold);
@@ -406,129 +393,102 @@ export function createGamepadController(options: GamepadControllerOptions) {
const config = options.getConfig();
const connectedGamepads = getConnectedGamepads();
const activeGamepad = resolveActiveGamepad(connectedGamepads, config);
const previousActiveGamepadId = activeGamepadId;
publishState(connectedGamepads, activeGamepad);
if (!activeGamepad) {
previousButtons = new Map();
previousActions = new Map();
resetHeldAction(selectionHold);
resetHeldAction(jumpHold);
lastPollAt = null;
return;
}
const interactionAllowed =
if (activeGamepad.id !== previousActiveGamepadId) {
previousActions = new Map();
resetHeldAction(selectionHold);
resetHeldAction(jumpHold);
}
let interactionAllowed =
config.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked();
if (config.enabled) {
handleButtonEdge(
handleActionEdge(
'toggleKeyboardOnlyMode',
config.bindings.toggleKeyboardOnlyMode,
normalizeButtonState(
activeGamepad,
config,
config.bindings.toggleKeyboardOnlyMode,
config.triggerInputMode,
config.triggerDeadzone,
),
activeGamepad,
config,
options.toggleKeyboardMode,
);
}
interactionAllowed =
config.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked();
if (!interactionAllowed) {
syncBlockedInteractionState(activeGamepad, config, now);
return;
}
handleButtonEdge(
handleActionEdge(
'toggleLookup',
config.bindings.toggleLookup,
normalizeButtonState(
activeGamepad,
config,
config.bindings.toggleLookup,
config.triggerInputMode,
config.triggerDeadzone,
),
activeGamepad,
config,
options.toggleLookup,
);
handleButtonEdge(
handleActionEdge(
'closeLookup',
config.bindings.closeLookup,
normalizeButtonState(
activeGamepad,
config,
config.bindings.closeLookup,
config.triggerInputMode,
config.triggerDeadzone,
),
activeGamepad,
config,
options.closeLookup,
);
handleButtonEdge(
config.bindings.mineCard,
normalizeButtonState(
activeGamepad,
config,
config.bindings.mineCard,
config.triggerInputMode,
config.triggerDeadzone,
),
options.mineCard,
);
handleButtonEdge(
config.bindings.quitMpv,
normalizeButtonState(
activeGamepad,
config,
config.bindings.quitMpv,
config.triggerInputMode,
config.triggerDeadzone,
),
options.quitMpv,
);
handleActionEdge('mineCard', config.bindings.mineCard, activeGamepad, config, options.mineCard);
handleActionEdge('quitMpv', config.bindings.quitMpv, activeGamepad, config, options.quitMpv);
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
if (options.getLookupWindowOpen()) {
handleButtonEdge(
handleActionEdge(
'previousAudio',
config.bindings.previousAudio,
normalizeButtonState(
activeGamepad,
config,
config.bindings.previousAudio,
config.triggerInputMode,
config.triggerDeadzone,
),
activeGamepad,
config,
options.previousAudio,
);
handleButtonEdge(
handleActionEdge(
'nextAudio',
config.bindings.nextAudio,
normalizeButtonState(
activeGamepad,
config,
config.bindings.nextAudio,
config.triggerInputMode,
config.triggerDeadzone,
),
activeGamepad,
config,
options.nextAudio,
);
handleButtonEdge(
handleActionEdge(
'playCurrentAudio',
config.bindings.playCurrentAudio,
normalizeButtonState(
activeGamepad,
config,
config.bindings.playCurrentAudio,
config.triggerInputMode,
config.triggerDeadzone,
),
activeGamepad,
config,
options.playCurrentAudio,
);
const dpadVertical = resolveDpadVerticalValue(activeGamepad, config.triggerDeadzone);
const primaryScroll = resolveAxisValue(activeGamepad, config.bindings.leftStickVertical);
if (elapsedMs > 0) {
if (Math.abs(primaryScroll) >= config.stickDeadzone) {
options.scrollPopup((primaryScroll * config.scrollPixelsPerSecond * elapsedMs) / 1000);
}
if (dpadVertical !== 0) {
options.scrollPopup((dpadVertical * config.scrollPixelsPerSecond * elapsedMs) / 1000);
}
const primaryScroll = resolveAxisBindingValue(
activeGamepad,
config.bindings.leftStickVertical,
config.triggerDeadzone,
config.stickDeadzone,
);
if (elapsedMs > 0 && Math.abs(primaryScroll) >= config.stickDeadzone) {
options.scrollPopup((primaryScroll * config.scrollPixelsPerSecond * elapsedMs) / 1000);
}
handleJumpAxis(
resolveAxisValue(activeGamepad, config.bindings.rightStickVertical),
resolveAxisBindingValue(
activeGamepad,
config.bindings.rightStickVertical,
config.triggerDeadzone,
activationThreshold,
),
now,
config,
);
@@ -536,26 +496,21 @@ export function createGamepadController(options: GamepadControllerOptions) {
resetHeldAction(jumpHold);
}
handleButtonEdge(
handleActionEdge(
'toggleMpvPause',
config.bindings.toggleMpvPause,
normalizeButtonState(
activeGamepad,
config,
config.bindings.toggleMpvPause,
config.triggerInputMode,
config.triggerDeadzone,
),
activeGamepad,
config,
options.toggleMpvPause,
);
handleSelectionAxis(
(() => {
const axisValue = resolveAxisValue(activeGamepad, config.bindings.leftStickHorizontal);
if (Math.abs(axisValue) >= Math.max(config.stickDeadzone, 0.55)) {
return axisValue;
}
return resolveDpadHorizontalValue(activeGamepad, config.triggerDeadzone);
})(),
resolveAxisBindingValue(
activeGamepad,
config.bindings.leftStickHorizontal,
config.triggerDeadzone,
activationThreshold,
),
now,
config,
);