mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-09 16:19:25 -07:00
fix: address latest controller review feedback
This commit is contained in:
@@ -771,6 +771,46 @@ test('gamepad controller trigger digital mode uses pressed state only', () => {
|
|||||||
assert.deepEqual(calls, ['play-audio', 'toggle-mpv-pause']);
|
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', () => {
|
test('gamepad controller maps L3 to mpv pause and keeps unbound audio action inactive', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||||
|
|||||||
@@ -69,6 +69,20 @@ function normalizeRawButtonState(
|
|||||||
return Boolean(button.pressed) || button.value >= triggerDeadzone;
|
return Boolean(button.pressed) || button.value >= triggerDeadzone;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveTriggerBindingPressed(
|
||||||
|
button: ControllerButtonState | undefined,
|
||||||
|
config: ResolvedControllerConfig,
|
||||||
|
): boolean {
|
||||||
|
if (!button) return false;
|
||||||
|
if (config.triggerInputMode === 'digital') {
|
||||||
|
return Boolean(button.pressed);
|
||||||
|
}
|
||||||
|
if (config.triggerInputMode === 'analog') {
|
||||||
|
return button.value >= config.triggerDeadzone;
|
||||||
|
}
|
||||||
|
return normalizeRawButtonState(button, config.triggerDeadzone);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveGamepadAxis(gamepad: GamepadLike, axisIndex: number): number {
|
function resolveGamepadAxis(gamepad: GamepadLike, axisIndex: number): number {
|
||||||
const value = gamepad.axes[axisIndex];
|
const value = gamepad.axes[axisIndex];
|
||||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
||||||
@@ -190,7 +204,13 @@ function resolveDiscreteBindingPressed(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (binding.kind === 'button') {
|
if (binding.kind === 'button') {
|
||||||
return normalizeRawButtonState(gamepad.buttons[binding.buttonIndex], config.triggerDeadzone);
|
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 activationThreshold = Math.max(config.stickDeadzone, 0.55);
|
||||||
|
|||||||
@@ -142,6 +142,10 @@ function buildContext() {
|
|||||||
return { state, dom };
|
return { state, dom };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findActionRow(container: ReturnType<typeof createFakeElement>, labelText: string) {
|
||||||
|
return container.children.find((child) => child.children?.[0]?.textContent === labelText) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
test('controller select modal saves preferred controller from dropdown selection', async () => {
|
test('controller select modal saves preferred controller from dropdown selection', async () => {
|
||||||
const domHandle = installFakeDom();
|
const domHandle = installFakeDom();
|
||||||
const saved: unknown[] = [];
|
const saved: unknown[] = [];
|
||||||
@@ -166,6 +170,7 @@ test('controller select modal saves preferred controller from dropdown selection
|
|||||||
syncSettingsModalSubtitleSuppression: () => {},
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modal.wireDomEvents();
|
||||||
modal.openControllerSelectModal();
|
modal.openControllerSelectModal();
|
||||||
state.controllerDeviceSelectedIndex = 1;
|
state.controllerDeviceSelectedIndex = 1;
|
||||||
|
|
||||||
@@ -210,6 +215,7 @@ test('controller select modal learn mode captures fresh button input and persist
|
|||||||
syncSettingsModalSubtitleSuppression: () => {},
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modal.wireDomEvents();
|
||||||
modal.openControllerSelectModal();
|
modal.openControllerSelectModal();
|
||||||
|
|
||||||
const firstRow = dom.controllerConfigList.children[1];
|
const firstRow = dom.controllerConfigList.children[1];
|
||||||
@@ -239,3 +245,101 @@ test('controller select modal learn mode captures fresh button input and persist
|
|||||||
domHandle.restore();
|
domHandle.restore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('controller select modal preserves saved axis dpad fallback while relearning', async () => {
|
||||||
|
const domHandle = installFakeDom();
|
||||||
|
const saved: unknown[] = [];
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
focus: () => {},
|
||||||
|
electronAPI: {
|
||||||
|
saveControllerConfig: async (update: unknown) => {
|
||||||
|
saved.push(update);
|
||||||
|
},
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { state, dom } = buildContext();
|
||||||
|
state.controllerConfig!.bindings.leftStickHorizontal = {
|
||||||
|
kind: 'axis',
|
||||||
|
axisIndex: 0,
|
||||||
|
dpadFallback: 'none',
|
||||||
|
};
|
||||||
|
|
||||||
|
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.openControllerSelectModal();
|
||||||
|
|
||||||
|
const tokenMoveRow = findActionRow(dom.controllerConfigList, 'Token Move');
|
||||||
|
assert.ok(tokenMoveRow);
|
||||||
|
const learnButton = tokenMoveRow.children[2].children[0];
|
||||||
|
learnButton.dispatch('click');
|
||||||
|
|
||||||
|
state.controllerRawAxes = [0, 0, 0.85];
|
||||||
|
modal.updateDevices();
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
assert.deepEqual(saved.at(-1), {
|
||||||
|
bindings: {
|
||||||
|
leftStickHorizontal: {
|
||||||
|
kind: 'axis',
|
||||||
|
axisIndex: 2,
|
||||||
|
dpadFallback: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
domHandle.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('controller select modal uses unique picker values for duplicate controller ids', async () => {
|
||||||
|
const domHandle = installFakeDom();
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
focus: () => {},
|
||||||
|
electronAPI: {
|
||||||
|
saveControllerConfig: async () => {},
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { state, dom } = buildContext();
|
||||||
|
state.connectedGamepads = [
|
||||||
|
{ id: 'same-pad', index: 0, mapping: 'standard', connected: true },
|
||||||
|
{ id: 'same-pad', index: 1, mapping: 'standard', connected: true },
|
||||||
|
];
|
||||||
|
state.activeGamepadId = 'same-pad';
|
||||||
|
|
||||||
|
const modal = createControllerSelectModal({ state, dom } as never, {
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.wireDomEvents();
|
||||||
|
modal.openControllerSelectModal();
|
||||||
|
|
||||||
|
const [firstOption, secondOption] = dom.controllerSelectPicker.children;
|
||||||
|
assert.notEqual(firstOption.value, secondOption.value);
|
||||||
|
|
||||||
|
dom.controllerSelectPicker.value = secondOption.value;
|
||||||
|
dom.controllerSelectPicker.dispatch('change');
|
||||||
|
|
||||||
|
assert.equal(state.controllerDeviceSelectedIndex, 1);
|
||||||
|
} finally {
|
||||||
|
domHandle.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function createControllerSelectModal(
|
|||||||
syncSettingsModalSubtitleSuppression: () => void;
|
syncSettingsModalSubtitleSuppression: () => void;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
let selectedControllerId: string | null = null;
|
let selectedControllerKey: string | null = null;
|
||||||
let lastRenderedDevicesKey = '';
|
let lastRenderedDevicesKey = '';
|
||||||
let lastRenderedActiveGamepadId: string | null = null;
|
let lastRenderedActiveGamepadId: string | null = null;
|
||||||
let lastRenderedPreferredId = '';
|
let lastRenderedPreferredId = '';
|
||||||
@@ -58,6 +58,7 @@ export function createControllerSelectModal(
|
|||||||
const definition = getControllerBindingDefinition(actionId);
|
const definition = getControllerBindingDefinition(actionId);
|
||||||
if (!definition) return;
|
if (!definition) return;
|
||||||
const config = ctx.state.controllerConfig;
|
const config = ctx.state.controllerConfig;
|
||||||
|
const currentBinding = config?.bindings[actionId];
|
||||||
bindingCapture = createControllerBindingCapture({
|
bindingCapture = createControllerBindingCapture({
|
||||||
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
|
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
|
||||||
stickDeadzone: config?.stickDeadzone ?? 0.2,
|
stickDeadzone: config?.stickDeadzone ?? 0.2,
|
||||||
@@ -68,7 +69,9 @@ export function createControllerSelectModal(
|
|||||||
actionId,
|
actionId,
|
||||||
bindingType: 'axis',
|
bindingType: 'axis',
|
||||||
dpadFallback:
|
dpadFallback:
|
||||||
definition.defaultBinding.kind === 'axis' &&
|
currentBinding?.kind === 'axis' && 'dpadFallback' in currentBinding
|
||||||
|
? currentBinding.dpadFallback
|
||||||
|
: definition.defaultBinding.kind === 'axis' &&
|
||||||
'dpadFallback' in definition.defaultBinding
|
'dpadFallback' in definition.defaultBinding
|
||||||
? definition.defaultBinding.dpadFallback
|
? definition.defaultBinding.dpadFallback
|
||||||
: 'none',
|
: 'none',
|
||||||
@@ -100,9 +103,13 @@ export function createControllerSelectModal(
|
|||||||
.join('||');
|
.join('||');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDeviceSelectionKey(device: { id: string; index: number }): string {
|
||||||
|
return `${device.id}:${device.index}`;
|
||||||
|
}
|
||||||
|
|
||||||
function syncSelectedControllerId(): void {
|
function syncSelectedControllerId(): void {
|
||||||
const selected = ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex];
|
const selected = ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex];
|
||||||
selectedControllerId = selected?.id ?? null;
|
selectedControllerKey = selected ? getDeviceSelectionKey(selected) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncSelectedIndexToCurrentController(): void {
|
function syncSelectedIndexToCurrentController(): void {
|
||||||
@@ -139,7 +146,7 @@ export function createControllerSelectModal(
|
|||||||
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
|
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
|
||||||
ctx.state.connectedGamepads.forEach((device, index) => {
|
ctx.state.connectedGamepads.forEach((device, index) => {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = device.id;
|
option.value = getDeviceSelectionKey(device);
|
||||||
option.selected = index === ctx.state.controllerDeviceSelectedIndex;
|
option.selected = index === ctx.state.controllerDeviceSelectedIndex;
|
||||||
option.textContent = `${device.id || `Gamepad ${device.index}`} (${[
|
option.textContent = `${device.id || `Gamepad ${device.index}`} (${[
|
||||||
`#${device.index}`,
|
`#${device.index}`,
|
||||||
@@ -226,9 +233,9 @@ export function createControllerSelectModal(
|
|||||||
|
|
||||||
function updateDevices(): void {
|
function updateDevices(): void {
|
||||||
if (!ctx.state.controllerSelectModalOpen) return;
|
if (!ctx.state.controllerSelectModalOpen) return;
|
||||||
if (selectedControllerId) {
|
if (selectedControllerKey) {
|
||||||
const preservedIndex = ctx.state.connectedGamepads.findIndex(
|
const preservedIndex = ctx.state.connectedGamepads.findIndex(
|
||||||
(device) => device.id === selectedControllerId,
|
(device) => getDeviceSelectionKey(device) === selectedControllerKey,
|
||||||
);
|
);
|
||||||
if (preservedIndex >= 0) {
|
if (preservedIndex >= 0) {
|
||||||
ctx.state.controllerDeviceSelectedIndex = preservedIndex;
|
ctx.state.controllerDeviceSelectedIndex = preservedIndex;
|
||||||
@@ -353,8 +360,10 @@ export function createControllerSelectModal(
|
|||||||
void saveSelectedController();
|
void saveSelectedController();
|
||||||
});
|
});
|
||||||
ctx.dom.controllerSelectPicker.addEventListener('change', () => {
|
ctx.dom.controllerSelectPicker.addEventListener('change', () => {
|
||||||
const selectedId = ctx.dom.controllerSelectPicker.value;
|
const selectedKey = ctx.dom.controllerSelectPicker.value;
|
||||||
const selectedIndex = ctx.state.connectedGamepads.findIndex((device) => device.id === selectedId);
|
const selectedIndex = ctx.state.connectedGamepads.findIndex(
|
||||||
|
(device) => getDeviceSelectionKey(device) === selectedKey,
|
||||||
|
);
|
||||||
if (selectedIndex >= 0) {
|
if (selectedIndex >= 0) {
|
||||||
ctx.state.controllerDeviceSelectedIndex = selectedIndex;
|
ctx.state.controllerDeviceSelectedIndex = selectedIndex;
|
||||||
syncSelectedControllerId();
|
syncSelectedControllerId();
|
||||||
|
|||||||
Reference in New Issue
Block a user