mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
* 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.
718 lines
22 KiB
TypeScript
718 lines
22 KiB
TypeScript
import type {
|
|
ControllerDeviceInfo,
|
|
ControllerRuntimeSnapshot,
|
|
ResolvedControllerAxisBinding,
|
|
ResolvedControllerConfig,
|
|
ResolvedControllerDiscreteBinding,
|
|
} from '../../types';
|
|
import { resolveControllerConfigForGamepad } from '../controller-profile-config.js';
|
|
import { createControllerComboTracker } from './controller-combo-tracker.js';
|
|
|
|
type ControllerButtonState = {
|
|
value: number;
|
|
pressed?: boolean;
|
|
touched?: boolean;
|
|
};
|
|
|
|
export type GamepadLike = {
|
|
id: string;
|
|
index: number;
|
|
connected: boolean;
|
|
mapping: string;
|
|
/** When the pad last reported new input (Gamepad API). */
|
|
timestamp?: number;
|
|
axes: readonly number[];
|
|
buttons: readonly ControllerButtonState[];
|
|
};
|
|
|
|
/**
|
|
* A screen that takes over every controller input while it is open (for example the
|
|
* media timing review). `activate` runs on the first poll of a new session; with
|
|
* `latchHeld` it ignores buttons already held (so the press that opened the screen does
|
|
* not fire), otherwise it treats them as fresh presses. `poll` runs on every later frame.
|
|
*/
|
|
export type ControllerScreenLayout = {
|
|
activate: (
|
|
gamepad: GamepadLike,
|
|
config: ResolvedControllerConfig,
|
|
now: number,
|
|
options: { latchHeld: boolean },
|
|
) => void;
|
|
poll: (gamepad: GamepadLike, config: ResolvedControllerConfig, now: number) => void;
|
|
};
|
|
|
|
type GamepadControllerOptions = {
|
|
getGamepads: () => Array<GamepadLike | null>;
|
|
getConfig: () => ResolvedControllerConfig;
|
|
getKeyboardModeEnabled: () => boolean;
|
|
getLookupWindowOpen: () => boolean;
|
|
getInteractionBlocked: () => boolean;
|
|
toggleKeyboardMode: () => void;
|
|
toggleLookup: () => void;
|
|
closeLookup: () => void;
|
|
moveSelection: (delta: -1 | 1) => void;
|
|
mineCard: () => void;
|
|
quitMpv: () => void;
|
|
previousAudio: () => void;
|
|
nextAudio: () => void;
|
|
playCurrentAudio: () => void;
|
|
previousEntry: () => void;
|
|
nextEntry: () => void;
|
|
/** Switches keyboard-only mode between moving by word and by character. */
|
|
toggleCharacterNavigation: () => void;
|
|
toggleMpvPause: () => void;
|
|
previousSubtitle: () => void;
|
|
nextSubtitle: () => void;
|
|
replaySubtitle: () => void;
|
|
playNextSubtitle: () => void;
|
|
setSecondarySubHeld: (held: boolean) => void;
|
|
scrollPopup: (deltaPixels: number) => void;
|
|
jumpPopup: (deltaPixels: number) => void;
|
|
onState: (state: ControllerRuntimeSnapshot) => void;
|
|
/** Screen layout that currently owns the controller in this window, if any. */
|
|
getScreenLayout?: () => ControllerScreenLayout | null;
|
|
/** True while a modal in another window owns the controller; this window stands down. */
|
|
getSuspended?: () => boolean;
|
|
};
|
|
|
|
export type HoldState = {
|
|
repeatStarted: boolean;
|
|
direction: -1 | 1 | null;
|
|
lastFireAt: number;
|
|
initialFired: boolean;
|
|
};
|
|
|
|
/** Buttons read as the D-pad on every pad, whatever its reported mapping. */
|
|
export const DPAD_BUTTON_INDEX = {
|
|
up: 12,
|
|
down: 13,
|
|
left: 14,
|
|
right: 15,
|
|
} as const;
|
|
|
|
/** Axes some raw (non-standard) pads use for the D-pad. */
|
|
export const DPAD_AXIS_INDEX = {
|
|
horizontal: 6,
|
|
vertical: 7,
|
|
} as const;
|
|
|
|
export function normalizeRawButtonState(
|
|
button: ControllerButtonState | undefined,
|
|
triggerDeadzone: number,
|
|
): boolean {
|
|
if (!button) return false;
|
|
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 {
|
|
const value = gamepad.axes[axisIndex];
|
|
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
}
|
|
|
|
function resolveDpadValue(
|
|
gamepad: GamepadLike,
|
|
negativeIndex: number,
|
|
positiveIndex: number,
|
|
triggerDeadzone: number,
|
|
): number {
|
|
const negative = gamepad.buttons[negativeIndex];
|
|
const positive = gamepad.buttons[positiveIndex];
|
|
return (
|
|
(normalizeRawButtonState(positive, triggerDeadzone) ? 1 : 0) -
|
|
(normalizeRawButtonState(negative, triggerDeadzone) ? 1 : 0)
|
|
);
|
|
}
|
|
|
|
function resolveDpadAxisValue(gamepad: GamepadLike, axisIndex: number): number {
|
|
const value = resolveGamepadAxis(gamepad, axisIndex);
|
|
if (Math.abs(value) < 0.5) {
|
|
return 0;
|
|
}
|
|
return Math.sign(value);
|
|
}
|
|
|
|
export function resolveDpadHorizontalValue(gamepad: GamepadLike, triggerDeadzone: number): number {
|
|
const axisValue = resolveDpadAxisValue(gamepad, DPAD_AXIS_INDEX.horizontal);
|
|
if (axisValue !== 0) {
|
|
return axisValue;
|
|
}
|
|
return resolveDpadValue(
|
|
gamepad,
|
|
DPAD_BUTTON_INDEX.left,
|
|
DPAD_BUTTON_INDEX.right,
|
|
triggerDeadzone,
|
|
);
|
|
}
|
|
|
|
export function resolveDpadVerticalValue(gamepad: GamepadLike, triggerDeadzone: number): number {
|
|
const axisValue = resolveDpadAxisValue(gamepad, DPAD_AXIS_INDEX.vertical);
|
|
if (axisValue !== 0) {
|
|
return axisValue;
|
|
}
|
|
return resolveDpadValue(gamepad, DPAD_BUTTON_INDEX.up, DPAD_BUTTON_INDEX.down, triggerDeadzone);
|
|
}
|
|
|
|
function resolveConnectedGamepads(gamepads: Array<GamepadLike | null>): GamepadLike[] {
|
|
return gamepads
|
|
.filter((gamepad): gamepad is GamepadLike => Boolean(gamepad?.connected))
|
|
.sort((left, right) => left.index - right.index);
|
|
}
|
|
|
|
export function createHoldState(): HoldState {
|
|
return {
|
|
repeatStarted: false,
|
|
direction: null,
|
|
lastFireAt: 0,
|
|
initialFired: false,
|
|
};
|
|
}
|
|
|
|
export function shouldFireHeldAction(
|
|
state: HoldState,
|
|
now: number,
|
|
repeatDelayMs: number,
|
|
repeatIntervalMs: number,
|
|
): boolean {
|
|
if (!state.initialFired) {
|
|
state.initialFired = true;
|
|
state.lastFireAt = now;
|
|
return true;
|
|
}
|
|
|
|
const elapsed = now - state.lastFireAt;
|
|
const threshold = state.repeatStarted ? repeatIntervalMs : repeatDelayMs;
|
|
if (elapsed < threshold) {
|
|
return false;
|
|
}
|
|
|
|
state.repeatStarted = true;
|
|
state.lastFireAt = now;
|
|
return true;
|
|
}
|
|
|
|
export function resetHeldAction(state: HoldState): void {
|
|
state.repeatStarted = false;
|
|
state.direction = null;
|
|
state.lastFireAt = 0;
|
|
state.initialFired = false;
|
|
}
|
|
|
|
function syncHeldActionBlocked(
|
|
state: HoldState,
|
|
value: number,
|
|
now: number,
|
|
activationThreshold: number,
|
|
): void {
|
|
if (Math.abs(value) < activationThreshold) {
|
|
resetHeldAction(state);
|
|
return;
|
|
}
|
|
|
|
const direction = value > 0 ? 1 : -1;
|
|
state.repeatStarted = false;
|
|
state.direction = direction;
|
|
state.lastFireAt = now;
|
|
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,
|
|
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;
|
|
}
|
|
|
|
/** Discrete actions that only act while the lookup popup is open. */
|
|
const POPUP_ONLY_ACTIONS = [
|
|
'previousAudio',
|
|
'nextAudio',
|
|
'playCurrentAudio',
|
|
'previousEntry',
|
|
'nextEntry',
|
|
] as const;
|
|
|
|
/** Playback and subtitle actions; like the secondary hold, they do not need keyboard-only mode. */
|
|
const WATCHING_ACTIONS = [
|
|
'toggleMpvPause',
|
|
'previousSubtitle',
|
|
'nextSubtitle',
|
|
'replaySubtitle',
|
|
'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;
|
|
let lastPollAt: number | null = null;
|
|
let secondarySubHeld = false;
|
|
let activeScreenLayout: ControllerScreenLayout | null = null;
|
|
let screenLayoutWaitingForPad = false;
|
|
|
|
function getConnectedGamepads(): GamepadLike[] {
|
|
return resolveConnectedGamepads(options.getGamepads());
|
|
}
|
|
|
|
function resolveActiveGamepad(
|
|
gamepads: GamepadLike[],
|
|
config: ResolvedControllerConfig,
|
|
): GamepadLike | null {
|
|
if (gamepads.length === 0) return null;
|
|
if (config.preferredGamepadId.trim().length > 0) {
|
|
const preferred = gamepads.find((gamepad) => gamepad.id === config.preferredGamepadId);
|
|
if (preferred) {
|
|
return preferred;
|
|
}
|
|
}
|
|
return gamepads[0] ?? null;
|
|
}
|
|
|
|
function publishState(gamepads: GamepadLike[], activeGamepad: GamepadLike | null): void {
|
|
activeGamepadId = activeGamepad?.id ?? null;
|
|
options.onState({
|
|
connectedGamepads: gamepads.map((gamepad) => ({
|
|
id: gamepad.id,
|
|
index: gamepad.index,
|
|
mapping: gamepad.mapping,
|
|
connected: gamepad.connected,
|
|
timestamp: gamepad.timestamp,
|
|
})) satisfies ControllerDeviceInfo[],
|
|
activeGamepadId,
|
|
rawAxes: activeGamepad?.axes ? [...activeGamepad.axes] : [],
|
|
rawButtons: activeGamepad?.buttons
|
|
? activeGamepad.buttons.map((button) => ({
|
|
value: button.value,
|
|
pressed: Boolean(button.pressed),
|
|
touched: button.touched,
|
|
}))
|
|
: [],
|
|
});
|
|
}
|
|
|
|
// Level-triggered: reports every change in held state, so releasing the button,
|
|
// disconnecting the pad, disabling the controller, or opening a blocking modal
|
|
// all hide the secondary subtitle again instead of leaving it stuck visible.
|
|
function syncSecondarySubHold(held: boolean): void {
|
|
if (held === secondarySubHeld) return;
|
|
secondarySubHeld = held;
|
|
options.setSecondarySubHeld(held);
|
|
}
|
|
|
|
function handleActionEdge(
|
|
actionKey: string,
|
|
binding: ResolvedControllerDiscreteBinding,
|
|
activeGamepad: GamepadLike,
|
|
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 (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) {
|
|
resetHeldAction(selectionHold);
|
|
return;
|
|
}
|
|
|
|
const direction = value > 0 ? 1 : -1;
|
|
if (selectionHold.direction !== direction) {
|
|
resetHeldAction(selectionHold);
|
|
selectionHold.direction = direction;
|
|
}
|
|
|
|
if (shouldFireHeldAction(selectionHold, now, config.repeatDelayMs, config.repeatIntervalMs)) {
|
|
options.moveSelection(direction);
|
|
}
|
|
}
|
|
|
|
function handleJumpAxis(value: number, now: number, config: ResolvedControllerConfig): void {
|
|
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
|
|
if (Math.abs(value) < activationThreshold) {
|
|
resetHeldAction(jumpHold);
|
|
return;
|
|
}
|
|
|
|
const direction = value > 0 ? 1 : -1;
|
|
if (jumpHold.direction !== direction) {
|
|
resetHeldAction(jumpHold);
|
|
jumpHold.direction = direction;
|
|
}
|
|
|
|
if (shouldFireHeldAction(jumpHold, now, config.repeatDelayMs, config.repeatIntervalMs)) {
|
|
options.jumpPopup(direction * config.horizontalJumpPixels);
|
|
}
|
|
}
|
|
|
|
function syncBlockedInteractionState(
|
|
activeGamepad: GamepadLike,
|
|
config: ResolvedControllerConfig,
|
|
now: number,
|
|
): void {
|
|
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(
|
|
axisGamepad,
|
|
config.bindings.leftStickHorizontal,
|
|
config.triggerDeadzone,
|
|
activationThreshold,
|
|
);
|
|
syncHeldActionBlocked(selectionHold, selectionValue, now, activationThreshold);
|
|
|
|
if (options.getLookupWindowOpen()) {
|
|
syncHeldActionBlocked(
|
|
jumpHold,
|
|
resolveAxisBindingValue(
|
|
axisGamepad,
|
|
config.bindings.rightStickVertical,
|
|
config.triggerDeadzone,
|
|
activationThreshold,
|
|
),
|
|
now,
|
|
activationThreshold,
|
|
);
|
|
} else {
|
|
resetHeldAction(jumpHold);
|
|
}
|
|
}
|
|
|
|
function poll(now: number): void {
|
|
const elapsedMs = lastPollAt === null ? 0 : Math.max(now - lastPollAt, 0);
|
|
lastPollAt = now;
|
|
const config = options.getConfig();
|
|
const connectedGamepads = getConnectedGamepads();
|
|
const activeGamepad = resolveActiveGamepad(connectedGamepads, config);
|
|
const previousActiveGamepadId = activeGamepadId;
|
|
publishState(connectedGamepads, activeGamepad);
|
|
handledThisPoll.clear();
|
|
|
|
if (!activeGamepad) {
|
|
syncSecondarySubHold(false);
|
|
activeScreenLayout = null;
|
|
// Browsers hide a pad from a page until a button is pressed while it is open, and
|
|
// each review opens in a fresh window. When a screen is already waiting for the pad,
|
|
// 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;
|
|
return;
|
|
}
|
|
|
|
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
|
|
// keeps tracking the pad, so a button still held when control returns does not fire.
|
|
const screenLayout = activeConfig.enabled ? (options.getScreenLayout?.() ?? null) : null;
|
|
const suspended = screenLayout === null && (options.getSuspended?.() ?? false);
|
|
if (screenLayout !== null || suspended) {
|
|
syncSecondarySubHold(false);
|
|
syncBlockedInteractionState(activeGamepad, activeConfig, now);
|
|
if (screenLayout === null) {
|
|
activeScreenLayout = null;
|
|
screenLayoutWaitingForPad = false;
|
|
} else if (screenLayout !== activeScreenLayout) {
|
|
activeScreenLayout = screenLayout;
|
|
screenLayout.activate(activeGamepad, activeConfig, now, {
|
|
latchHeld: !screenLayoutWaitingForPad,
|
|
});
|
|
screenLayoutWaitingForPad = false;
|
|
} else {
|
|
screenLayout.poll(activeGamepad, activeConfig, now);
|
|
}
|
|
return;
|
|
}
|
|
activeScreenLayout = null;
|
|
screenLayoutWaitingForPad = false;
|
|
|
|
// Unlike most actions, this one does not require keyboard-only mode, so it can be
|
|
// used while simply watching.
|
|
syncSecondarySubHold(
|
|
activeConfig.enabled &&
|
|
!options.getInteractionBlocked() &&
|
|
resolveDiscreteBindingPressed(
|
|
activeGamepad,
|
|
activeConfig.bindings.holdSecondarySub,
|
|
activeConfig,
|
|
) &&
|
|
comboTracker.matches(activeConfig.bindings.holdSecondarySub),
|
|
);
|
|
|
|
// Pause and subtitle navigation also work while simply watching; a blocking modal pauses them.
|
|
if (activeConfig.enabled && !options.getInteractionBlocked()) {
|
|
for (const actionKey of WATCHING_ACTIONS) {
|
|
handleActionEdge(
|
|
actionKey,
|
|
activeConfig.bindings[actionKey],
|
|
activeGamepad,
|
|
activeConfig,
|
|
options[actionKey],
|
|
);
|
|
}
|
|
}
|
|
|
|
if (activeConfig.enabled) {
|
|
handleActionEdge(
|
|
'toggleKeyboardOnlyMode',
|
|
activeConfig.bindings.toggleKeyboardOnlyMode,
|
|
activeGamepad,
|
|
activeConfig,
|
|
options.toggleKeyboardMode,
|
|
);
|
|
}
|
|
|
|
const interactionAllowed =
|
|
activeConfig.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked();
|
|
|
|
if (!interactionAllowed) {
|
|
syncBlockedInteractionState(activeGamepad, activeConfig, now);
|
|
return;
|
|
}
|
|
|
|
handleActionEdge(
|
|
'toggleLookup',
|
|
activeConfig.bindings.toggleLookup,
|
|
activeGamepad,
|
|
activeConfig,
|
|
options.toggleLookup,
|
|
);
|
|
handleActionEdge(
|
|
'closeLookup',
|
|
activeConfig.bindings.closeLookup,
|
|
activeGamepad,
|
|
activeConfig,
|
|
options.closeLookup,
|
|
);
|
|
handleActionEdge(
|
|
'mineCard',
|
|
activeConfig.bindings.mineCard,
|
|
activeGamepad,
|
|
activeConfig,
|
|
options.mineCard,
|
|
);
|
|
handleActionEdge(
|
|
'quitMpv',
|
|
activeConfig.bindings.quitMpv,
|
|
activeGamepad,
|
|
activeConfig,
|
|
options.quitMpv,
|
|
);
|
|
handleActionEdge(
|
|
'toggleCharacterNavigation',
|
|
activeConfig.bindings.toggleCharacterNavigation,
|
|
activeGamepad,
|
|
activeConfig,
|
|
options.toggleCharacterNavigation,
|
|
);
|
|
|
|
const activationThreshold = Math.max(activeConfig.stickDeadzone, 0.55);
|
|
const axisGamepad = comboTracker.withoutClaimedInputs(activeGamepad);
|
|
|
|
if (options.getLookupWindowOpen()) {
|
|
handleActionEdge(
|
|
'previousAudio',
|
|
activeConfig.bindings.previousAudio,
|
|
activeGamepad,
|
|
activeConfig,
|
|
options.previousAudio,
|
|
);
|
|
handleActionEdge(
|
|
'nextAudio',
|
|
activeConfig.bindings.nextAudio,
|
|
activeGamepad,
|
|
activeConfig,
|
|
options.nextAudio,
|
|
);
|
|
handleActionEdge(
|
|
'playCurrentAudio',
|
|
activeConfig.bindings.playCurrentAudio,
|
|
activeGamepad,
|
|
activeConfig,
|
|
options.playCurrentAudio,
|
|
);
|
|
handleActionEdge(
|
|
'previousEntry',
|
|
activeConfig.bindings.previousEntry,
|
|
activeGamepad,
|
|
activeConfig,
|
|
options.previousEntry,
|
|
);
|
|
handleActionEdge(
|
|
'nextEntry',
|
|
activeConfig.bindings.nextEntry,
|
|
activeGamepad,
|
|
activeConfig,
|
|
options.nextEntry,
|
|
);
|
|
|
|
const primaryScroll = resolveAxisBindingValue(
|
|
axisGamepad,
|
|
activeConfig.bindings.leftStickVertical,
|
|
activeConfig.triggerDeadzone,
|
|
activeConfig.stickDeadzone,
|
|
);
|
|
if (elapsedMs > 0 && Math.abs(primaryScroll) >= activeConfig.stickDeadzone) {
|
|
options.scrollPopup(
|
|
(primaryScroll * activeConfig.scrollPixelsPerSecond * elapsedMs) / 1000,
|
|
);
|
|
}
|
|
|
|
handleJumpAxis(
|
|
resolveAxisBindingValue(
|
|
axisGamepad,
|
|
activeConfig.bindings.rightStickVertical,
|
|
activeConfig.triggerDeadzone,
|
|
activationThreshold,
|
|
),
|
|
now,
|
|
activeConfig,
|
|
);
|
|
} else {
|
|
resetHeldAction(jumpHold);
|
|
for (const actionKey of POPUP_ONLY_ACTIONS) {
|
|
trackActionWithoutFiring(actionKey, activeGamepad, activeConfig);
|
|
}
|
|
}
|
|
|
|
handleSelectionAxis(
|
|
resolveAxisBindingValue(
|
|
axisGamepad,
|
|
activeConfig.bindings.leftStickHorizontal,
|
|
activeConfig.triggerDeadzone,
|
|
activationThreshold,
|
|
),
|
|
now,
|
|
activeConfig,
|
|
);
|
|
}
|
|
|
|
return {
|
|
poll,
|
|
getActiveGamepadId: (): string | null => activeGamepadId,
|
|
};
|
|
}
|