mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-09 16:19:25 -07:00
feat(controller): add inline remap modal with descriptor-based bindings (#21)
This commit is contained in:
129
src/renderer/handlers/controller-binding-capture.test.ts
Normal file
129
src/renderer/handlers/controller-binding-capture.test.ts
Normal 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' },
|
||||
});
|
||||
});
|
||||
194
src/renderer/handlers/controller-binding-capture.ts
Normal file
194
src/renderer/handlers/controller-binding-capture.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user