mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-12 16:46:07 -08:00
feat(controller): add hold + press combos to main bindings (#328)
* feat(controller): add hold + press combos to main bindings Main Alt+C actions can now be learned as combos such as L1 + D-pad right. A combo fires on each press while its hold button is down, and claims its press input so token move and popup scroll ignore it until it is released. A plain action on a hold button fires on release, unless another bound button was pressed during the hold. The save path needed two changes for combos to persist. The IPC validator rebuilt bindings field by field and dropped `hold`, so Alt+C showed a combo from its local copy until the next reload turned it back into a plain button. Validator and config resolver now share one parser. Saves also replace the controller section, since the deep merge in patchRawConfig kept the old `hold` when a combo was rebound to one button. Also fixes Learn on the (D-pad) rows, which threw after cancel() cleared the capture target. * test(launcher): wait for content in background child test The test polled for the fake app's output files to exist, but the shell creates a redirect target before writing to it. A poll landing in that gap read an empty env file and failed with '' !== '1', as seen once in CI. Wait until both files have content instead. * fix(controller): keep a diagonal push from learning as a combo Learn took the first axis of a diagonal stick push as the hold and the second as the press, saving a combo that one axis could never trigger. This reached main Alt+C rows when they moved to combo capture. Axes crossing the threshold in the same poll as the first input now count as one movement. A second axis moved in a later poll still records a combo. * fix(controller): only treat simultaneous axes as one movement The diagonal push guard also blocked an axis activated in the same poll as a button, so L1 plus a stick direction could not record the stick until it was released and moved again. Apply the guard only when the first input is an axis, so that input pair records as a combo again.
This commit is contained in:
1 parent
79e5535823
commit
b31ddce627
30 files changed
+1078
-295
No files matched your search
@@ -21,45 +21,27 @@ function createSnapshot(
|
||||
};
|
||||
}
|
||||
|
||||
test('controller binding capture waits for neutral-to-active button edge', () => {
|
||||
test('controller binding capture waits for a neutral-to-active D-pad edge', () => {
|
||||
const capture = createControllerBindingCapture({
|
||||
triggerDeadzone: 0.5,
|
||||
stickDeadzone: 0.2,
|
||||
});
|
||||
|
||||
const heldButtons = createSnapshot({
|
||||
buttons: [{ value: 1, pressed: true, touched: true }],
|
||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
||||
const heldDpad = createSnapshot({
|
||||
buttons: buttons.map((button, index) =>
|
||||
index === 15 ? { value: 1, pressed: true, touched: true } : button,
|
||||
),
|
||||
});
|
||||
|
||||
capture.arm({ actionId: 'toggleLookup', bindingType: 'discrete' }, heldButtons);
|
||||
capture.arm({ actionId: 'leftStickHorizontal', bindingType: 'dpad' }, heldDpad);
|
||||
|
||||
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' },
|
||||
assert.equal(capture.poll(heldDpad), null);
|
||||
assert.equal(capture.poll(createSnapshot({ buttons })), null);
|
||||
assert.deepEqual(capture.poll(heldDpad), {
|
||||
actionId: 'leftStickHorizontal',
|
||||
bindingType: 'dpad',
|
||||
dpadDirection: 'horizontal',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,7 +51,10 @@ test('controller binding capture ignores analog drift inside deadzone', () => {
|
||||
stickDeadzone: 0.3,
|
||||
});
|
||||
|
||||
capture.arm({ actionId: 'mineCard', bindingType: 'discrete' }, createSnapshot());
|
||||
capture.arm(
|
||||
{ actionId: 'leftStickHorizontal', bindingType: 'axis', dpadFallback: 'none' },
|
||||
createSnapshot(),
|
||||
);
|
||||
|
||||
assert.equal(capture.poll(createSnapshot({ axes: [0.2, 0, 0, 0] })), null);
|
||||
assert.equal(capture.isArmed(), true);
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type {
|
||||
ControllerDpadFallback,
|
||||
ResolvedControllerAxisBinding,
|
||||
ResolvedControllerDiscreteBinding,
|
||||
} from '../../types';
|
||||
import type { ControllerDpadFallback, ResolvedControllerAxisBinding } from '../../types';
|
||||
|
||||
type ControllerButtonState = {
|
||||
value: number;
|
||||
@@ -15,11 +11,8 @@ type ControllerBindingCaptureSnapshot = {
|
||||
buttons: readonly ControllerButtonState[];
|
||||
};
|
||||
|
||||
/** Learn for stick rows and their D-pad rows; discrete rows use the combo capture. */
|
||||
type ControllerBindingCaptureTarget =
|
||||
| {
|
||||
actionId: string;
|
||||
bindingType: 'discrete';
|
||||
}
|
||||
| {
|
||||
actionId: string;
|
||||
bindingType: 'axis';
|
||||
@@ -31,11 +24,6 @@ type ControllerBindingCaptureTarget =
|
||||
};
|
||||
|
||||
type ControllerBindingCaptureResult =
|
||||
| {
|
||||
actionId: string;
|
||||
bindingType: 'discrete';
|
||||
binding: ResolvedControllerDiscreteBinding;
|
||||
}
|
||||
| {
|
||||
actionId: string;
|
||||
bindingType: 'axis';
|
||||
@@ -134,58 +122,24 @@ export function createControllerBindingCapture(options: {
|
||||
|
||||
const dpadDirection: ControllerDpadFallback =
|
||||
index === 12 || index === 13 ? 'vertical' : 'horizontal';
|
||||
const { actionId } = target;
|
||||
cancel();
|
||||
return {
|
||||
actionId: target.actionId,
|
||||
bindingType: 'dpad' as const,
|
||||
dpadDirection,
|
||||
};
|
||||
return { actionId, bindingType: 'dpad', 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,
|
||||
},
|
||||
};
|
||||
const result: ControllerBindingCaptureResult = {
|
||||
actionId: target.actionId,
|
||||
bindingType: 'axis',
|
||||
binding: { kind: 'axis', axisIndex: index, dpadFallback: target.dpadFallback },
|
||||
};
|
||||
cancel();
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -73,3 +73,49 @@ test('combo capture never records a trigger axis resting position', () => {
|
||||
hold: { kind: 'axis', axisIndex: 2, direction: 'positive' },
|
||||
});
|
||||
});
|
||||
|
||||
test('combo capture records one axis from a diagonal push, not a hold and a press', () => {
|
||||
const capture = createCapture();
|
||||
capture.arm(snapshot());
|
||||
const diagonal = snapshot([], [0.9, 0.9, 0, 0]);
|
||||
|
||||
const hold = { kind: 'axis', axisIndex: 0, direction: 'positive' } as const;
|
||||
assert.deepEqual(capture.poll(diagonal), { status: 'holding', hold });
|
||||
assert.deepEqual(capture.poll(diagonal), { status: 'holding', hold });
|
||||
assert.deepEqual(capture.poll(snapshot()), {
|
||||
status: 'done',
|
||||
binding: { kind: 'combo', press: hold, hold: null },
|
||||
});
|
||||
});
|
||||
|
||||
test('combo capture still records a second axis pressed after the first', () => {
|
||||
const capture = createCapture();
|
||||
capture.arm(snapshot());
|
||||
|
||||
capture.poll(snapshot([], [0.9, 0, 0, 0]));
|
||||
assert.deepEqual(capture.poll(snapshot([], [0.9, 0.9, 0, 0])), {
|
||||
status: 'done',
|
||||
binding: {
|
||||
kind: 'combo',
|
||||
press: { kind: 'axis', axisIndex: 1, direction: 'positive' },
|
||||
hold: { kind: 'axis', axisIndex: 0, direction: 'positive' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('combo capture records a button and an axis activated in the same poll as a combo', () => {
|
||||
const capture = createCapture();
|
||||
capture.arm(snapshot());
|
||||
const both = snapshot([4], [0.9, 0, 0, 0]);
|
||||
|
||||
const hold = { kind: 'button', buttonIndex: 4 } as const;
|
||||
assert.deepEqual(capture.poll(both), { status: 'holding', hold });
|
||||
assert.deepEqual(capture.poll(both), {
|
||||
status: 'done',
|
||||
binding: {
|
||||
kind: 'combo',
|
||||
press: { kind: 'axis', axisIndex: 0, direction: 'positive' },
|
||||
hold,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ControllerReviewInput, ResolvedControllerReviewBinding } from '../../types';
|
||||
import type { ControllerRawInput } from '../../types';
|
||||
|
||||
type ControllerButtonState = {
|
||||
value: number;
|
||||
@@ -13,19 +13,23 @@ type ControllerSnapshot = {
|
||||
|
||||
export type ControllerComboCaptureState =
|
||||
| { status: 'waiting' }
|
||||
| { status: 'holding'; hold: ControllerReviewInput }
|
||||
| { status: 'done'; binding: ResolvedControllerReviewBinding };
|
||||
| { status: 'holding'; hold: ControllerRawInput }
|
||||
| { status: 'done'; binding: ControllerCapturedCombo };
|
||||
|
||||
type RawInput = Extract<ControllerReviewInput, { kind: 'button' | 'axis' }>;
|
||||
export type ControllerCapturedCombo = {
|
||||
kind: 'combo';
|
||||
press: ControllerRawInput;
|
||||
hold: ControllerRawInput | null;
|
||||
};
|
||||
|
||||
function inputKey(input: RawInput): string {
|
||||
function inputKey(input: ControllerRawInput): string {
|
||||
return input.kind === 'button'
|
||||
? `button:${input.buttonIndex}`
|
||||
: `axis:${input.axisIndex}:${input.direction}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Learn for review bindings. Waits for a first input; letting go of it records it alone,
|
||||
* Learn for discrete and dialog bindings. Waits for a first input; letting go of it records it alone,
|
||||
* while pressing a second input during the hold records "hold first + press second".
|
||||
* Buttons already down when learning starts are ignored until released. Axis directions
|
||||
* active at the start stay ignored for the whole capture: some pads rest a trigger axis at
|
||||
@@ -37,10 +41,10 @@ export function createControllerComboCapture(options: {
|
||||
}) {
|
||||
const blocked = new Set<string>();
|
||||
const restingAxes = new Set<string>();
|
||||
let first: RawInput | null = null;
|
||||
let first: ControllerRawInput | null = null;
|
||||
|
||||
function activeInputs(snapshot: ControllerSnapshot): RawInput[] {
|
||||
const inputs: RawInput[] = [];
|
||||
function activeInputs(snapshot: ControllerSnapshot): ControllerRawInput[] {
|
||||
const inputs: ControllerRawInput[] = [];
|
||||
snapshot.buttons.forEach((button, buttonIndex) => {
|
||||
if (button && (Boolean(button.pressed) || button.value >= options.triggerDeadzone)) {
|
||||
inputs.push({ kind: 'button', buttonIndex });
|
||||
@@ -77,6 +81,11 @@ export function createControllerComboCapture(options: {
|
||||
const next = fresh[0];
|
||||
if (!next) return { status: 'waiting' };
|
||||
first = next;
|
||||
// Axes crossing the threshold in the same poll are one movement, such as a diagonal push.
|
||||
// Buttons sort first, so an axis here means every other fresh input is an axis too.
|
||||
if (next.kind === 'axis') {
|
||||
for (const input of fresh.slice(1)) blocked.add(inputKey(input));
|
||||
}
|
||||
return { status: 'holding', hold: first };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { controllerReviewInputSourceKey as inputKey } from '../../shared/controller-review';
|
||||
import type { ControllerRawInput, ResolvedControllerDiscreteBinding } from '../../types';
|
||||
import type { GamepadLike } from './gamepad-controller.js';
|
||||
|
||||
type BoundBinding = Exclude<ResolvedControllerDiscreteBinding, { kind: 'none' }>;
|
||||
|
||||
const isBound = (binding: ResolvedControllerDiscreteBinding): binding is BoundBinding =>
|
||||
binding.kind !== 'none';
|
||||
|
||||
/**
|
||||
* Per-poll view of hold + press combos in the main controller bindings. Dialog layouts have
|
||||
* their own matching in `controller-screen-layout.ts`; the rules for choosing a binding match.
|
||||
*
|
||||
* A press belongs to the binding that matches the hold buttons down when it starts, so
|
||||
* pressing the same input again during one hold fires the combo again, and letting go of the
|
||||
* hold first does not turn the press into its plain binding.
|
||||
*/
|
||||
export function createControllerComboTracker() {
|
||||
let pressedLastPoll: ReadonlySet<string> = new Set();
|
||||
let holdKeys: ReadonlySet<string> = new Set();
|
||||
let heldHoldKeys: string[] = [];
|
||||
let usedHolds: ReadonlySet<string> = new Set();
|
||||
/** Holds used during their press, kept for the poll that sees them released. */
|
||||
let cancelledTaps: ReadonlySet<string> = new Set();
|
||||
const claimedInputs = new Map<string, ControllerRawInput>();
|
||||
|
||||
function update(
|
||||
bindings: readonly ResolvedControllerDiscreteBinding[],
|
||||
isPressed: (input: ControllerRawInput) => boolean,
|
||||
): void {
|
||||
const bound = bindings.filter(isBound);
|
||||
const pressed = new Set<string>();
|
||||
for (const binding of bound) {
|
||||
for (const input of binding.hold ? [binding, binding.hold] : [binding]) {
|
||||
if (isPressed(input)) pressed.add(inputKey(input));
|
||||
}
|
||||
}
|
||||
|
||||
holdKeys = new Set(bound.flatMap((binding) => (binding.hold ? [inputKey(binding.hold)] : [])));
|
||||
heldHoldKeys = [...holdKeys].filter((key) => pressed.has(key));
|
||||
|
||||
const freshPresses = bound
|
||||
.map(inputKey)
|
||||
.filter((key) => pressed.has(key) && !pressedLastPoll.has(key));
|
||||
const nextUsedHolds = new Set(
|
||||
heldHoldKeys.filter(
|
||||
(holdKey) => usedHolds.has(holdKey) || freshPresses.some((key) => key !== holdKey),
|
||||
),
|
||||
);
|
||||
cancelledTaps = new Set([...usedHolds, ...nextUsedHolds]);
|
||||
usedHolds = nextUsedHolds;
|
||||
pressedLastPoll = pressed;
|
||||
|
||||
for (const key of claimedInputs.keys()) {
|
||||
if (!pressed.has(key)) claimedInputs.delete(key);
|
||||
}
|
||||
for (const binding of bound) {
|
||||
if (!binding.hold) continue;
|
||||
const pressKey = inputKey(binding);
|
||||
const holdKey = inputKey(binding.hold);
|
||||
if (pressKey !== holdKey && pressed.has(pressKey) && pressed.has(holdKey)) {
|
||||
claimedInputs.set(pressKey, binding);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
update,
|
||||
|
||||
reset(): void {
|
||||
pressedLastPoll = new Set();
|
||||
holdKeys = new Set();
|
||||
heldHoldKeys = [];
|
||||
usedHolds = new Set();
|
||||
cancelledTaps = new Set();
|
||||
claimedInputs.clear();
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether a press of this binding counts right now: with one hold button down, only the
|
||||
* binding that uses it; with none, only a plain binding. Two holds down match nothing.
|
||||
*/
|
||||
matches(binding: ResolvedControllerDiscreteBinding): boolean {
|
||||
if (!isBound(binding)) return false;
|
||||
const pressKey = inputKey(binding);
|
||||
const others = heldHoldKeys.filter((key) => key !== pressKey);
|
||||
if (others.length > 1) return false;
|
||||
return others[0] === (binding.hold ? inputKey(binding.hold) : undefined);
|
||||
},
|
||||
|
||||
/** A plain binding on a hold button waits for release, when a tap can be told from a hold. */
|
||||
firesOnRelease(binding: ResolvedControllerDiscreteBinding): boolean {
|
||||
return isBound(binding) && !binding.hold && holdKeys.has(inputKey(binding));
|
||||
},
|
||||
|
||||
/** Another bound input was pressed during this hold, so its release is not a tap. */
|
||||
isTapCancelled(binding: ResolvedControllerDiscreteBinding): boolean {
|
||||
return isBound(binding) && cancelledTaps.has(inputKey(binding));
|
||||
},
|
||||
|
||||
/**
|
||||
* The pad with inputs claimed by a combo reading as released, for the stick actions and
|
||||
* their D-pad fallbacks. A claim lasts until the input is released, so letting go of the
|
||||
* hold first does not start moving the word selection.
|
||||
*/
|
||||
withoutClaimedInputs(gamepad: GamepadLike): GamepadLike {
|
||||
if (claimedInputs.size === 0) return gamepad;
|
||||
const axes = [...gamepad.axes];
|
||||
const buttons = [...gamepad.buttons];
|
||||
for (const input of claimedInputs.values()) {
|
||||
if (input.kind === 'button') {
|
||||
buttons[input.buttonIndex] = { value: 0, pressed: false, touched: false };
|
||||
} else if ((axes[input.axisIndex] ?? 0) > 0 === (input.direction === 'positive')) {
|
||||
axes[input.axisIndex] = 0;
|
||||
}
|
||||
}
|
||||
// Native Gamepad fields are prototype getters, so object spread would drop them.
|
||||
return {
|
||||
id: gamepad.id,
|
||||
index: gamepad.index,
|
||||
connected: gamepad.connected,
|
||||
mapping: gamepad.mapping,
|
||||
timestamp: gamepad.timestamp,
|
||||
axes,
|
||||
buttons,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -919,3 +919,261 @@ test('gamepad controller pauses mpv outside keyboard-only mode, but not under a
|
||||
|
||||
assert.deepEqual(harness.calls, ['toggleMpvPause']);
|
||||
});
|
||||
|
||||
/** Harness driven frame by frame: `pads(4, 15)` polls once with those buttons down. */
|
||||
function createComboHarness(
|
||||
bindings: Partial<Bindings>,
|
||||
deps: Partial<ControllerOptions> = {},
|
||||
extraConfig: Partial<ResolvedControllerConfig> = {},
|
||||
) {
|
||||
let pressed: number[] = [];
|
||||
let now = 0;
|
||||
const harness = createHarness({
|
||||
gamepads: () => [
|
||||
createGamepad('pad-1', {
|
||||
buttons: Object.fromEntries(pressed.map((index) => [index, PRESSED])),
|
||||
}),
|
||||
],
|
||||
config: createControllerConfig({ ...extraConfig, bindings }),
|
||||
deps: { getLookupWindowOpen: () => true, ...deps },
|
||||
});
|
||||
return {
|
||||
...harness,
|
||||
pads: (...buttons: number[]) => {
|
||||
pressed = buttons;
|
||||
now += 16;
|
||||
harness.poll(now);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const L1 = 4;
|
||||
const R1 = 5;
|
||||
const DPAD_UP = 12;
|
||||
const DPAD_RIGHT = 15;
|
||||
const L1_DPAD_RIGHT = {
|
||||
kind: 'button',
|
||||
buttonIndex: DPAD_RIGHT,
|
||||
hold: { kind: 'button', buttonIndex: L1 },
|
||||
} as const;
|
||||
|
||||
test('holding L1 and pressing D-pad right twice fires the combo twice and nothing else', () => {
|
||||
// L1 keeps its default Play Current Audio, and the D-pad keeps moving between words.
|
||||
const harness = createComboHarness({ nextAudio: L1_DPAD_RIGHT });
|
||||
|
||||
harness.pads();
|
||||
harness.pads(L1);
|
||||
harness.pads(L1, DPAD_RIGHT);
|
||||
harness.pads(L1);
|
||||
harness.pads(L1, DPAD_RIGHT);
|
||||
harness.pads(L1);
|
||||
harness.pads();
|
||||
|
||||
assert.deepEqual(harness.calls, ['nextAudio', 'nextAudio']);
|
||||
});
|
||||
|
||||
test('a plain button that is also a hold button fires when tapped, on release', () => {
|
||||
const harness = createComboHarness({ nextAudio: L1_DPAD_RIGHT });
|
||||
|
||||
harness.pads();
|
||||
harness.pads(L1);
|
||||
assert.deepEqual(harness.calls, []);
|
||||
harness.pads();
|
||||
assert.deepEqual(harness.calls, ['playCurrentAudio']);
|
||||
|
||||
// The next tap is unaffected by a combo used on an earlier hold.
|
||||
harness.pads(L1);
|
||||
harness.pads(L1, DPAD_RIGHT);
|
||||
harness.pads();
|
||||
harness.pads(L1);
|
||||
harness.pads();
|
||||
assert.deepEqual(harness.calls, ['playCurrentAudio', 'nextAudio', 'playCurrentAudio']);
|
||||
});
|
||||
|
||||
test('a plain button stays on press while no combo holds it', () => {
|
||||
const harness = createComboHarness({ nextAudio: { kind: 'button', buttonIndex: R1 } });
|
||||
|
||||
harness.pads();
|
||||
harness.pads(L1);
|
||||
|
||||
assert.deepEqual(harness.calls, ['playCurrentAudio']);
|
||||
});
|
||||
|
||||
test('a tap on a hold button also fires outside keyboard-only mode', () => {
|
||||
const harness = createComboHarness(
|
||||
{
|
||||
previousSubtitle: { kind: 'button', buttonIndex: L1 },
|
||||
nextSubtitle: { kind: 'button', buttonIndex: R1, hold: { kind: 'button', buttonIndex: L1 } },
|
||||
},
|
||||
{ getKeyboardModeEnabled: () => false },
|
||||
);
|
||||
|
||||
harness.pads();
|
||||
harness.pads(L1);
|
||||
harness.pads();
|
||||
|
||||
assert.deepEqual(harness.calls, ['previousSubtitle']);
|
||||
});
|
||||
|
||||
test('a tap on a hold button is dropped if its action stops applying before the release', () => {
|
||||
let lookupOpen = true;
|
||||
const harness = createComboHarness(
|
||||
{ nextAudio: L1_DPAD_RIGHT },
|
||||
{ getLookupWindowOpen: () => lookupOpen },
|
||||
);
|
||||
|
||||
harness.pads();
|
||||
harness.pads(L1);
|
||||
lookupOpen = false;
|
||||
harness.pads(L1);
|
||||
lookupOpen = true;
|
||||
harness.pads(L1);
|
||||
harness.pads();
|
||||
|
||||
assert.deepEqual(harness.calls, []);
|
||||
});
|
||||
|
||||
test('pressing a plain binding during a hold does nothing and cancels the tap', () => {
|
||||
const harness = createComboHarness({ nextAudio: L1_DPAD_RIGHT });
|
||||
|
||||
harness.pads();
|
||||
harness.pads(L1);
|
||||
harness.pads(L1, 2); // X is Mine Card; there is no L1 + X.
|
||||
harness.pads();
|
||||
|
||||
assert.deepEqual(harness.calls, []);
|
||||
});
|
||||
|
||||
test('letting go of the hold first does not fire the plain binding on the same input', () => {
|
||||
const harness = createComboHarness({
|
||||
playCurrentAudio: { kind: 'none' },
|
||||
nextAudio: L1_DPAD_RIGHT,
|
||||
nextEntry: { kind: 'button', buttonIndex: DPAD_RIGHT },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'none' },
|
||||
});
|
||||
|
||||
harness.pads();
|
||||
harness.pads(L1);
|
||||
harness.pads(L1, DPAD_RIGHT);
|
||||
harness.pads(DPAD_RIGHT);
|
||||
harness.pads();
|
||||
assert.deepEqual(harness.calls, ['nextAudio']);
|
||||
|
||||
harness.pads(DPAD_RIGHT);
|
||||
assert.deepEqual(harness.calls, ['nextAudio', 'nextEntry']);
|
||||
});
|
||||
|
||||
test('a combo needs its hold down first, and two holds at once match nothing', () => {
|
||||
const harness = createComboHarness({
|
||||
playCurrentAudio: { kind: 'none' },
|
||||
nextAudio: L1_DPAD_RIGHT,
|
||||
previousAudio: {
|
||||
kind: 'button',
|
||||
buttonIndex: DPAD_RIGHT,
|
||||
hold: { kind: 'button', buttonIndex: R1 },
|
||||
},
|
||||
});
|
||||
|
||||
harness.pads();
|
||||
harness.pads(DPAD_RIGHT);
|
||||
harness.pads(L1, DPAD_RIGHT);
|
||||
harness.pads();
|
||||
harness.pads(L1, R1);
|
||||
harness.pads(L1, R1, DPAD_RIGHT);
|
||||
|
||||
// Only the first press of the D-pad moves a word; the combos never fire.
|
||||
assert.deepEqual(harness.calls, ['moveSelection:1']);
|
||||
});
|
||||
|
||||
test('a combo keeps the D-pad from driving its stick row until the D-pad is released', () => {
|
||||
const harness = createComboHarness({
|
||||
playCurrentAudio: { kind: 'none' },
|
||||
nextAudio: L1_DPAD_RIGHT,
|
||||
nextEntry: {
|
||||
kind: 'button',
|
||||
buttonIndex: DPAD_UP,
|
||||
hold: { kind: 'button', buttonIndex: L1 },
|
||||
},
|
||||
});
|
||||
|
||||
harness.pads();
|
||||
harness.pads(L1);
|
||||
harness.pads(L1, DPAD_RIGHT);
|
||||
harness.pads(L1, DPAD_UP);
|
||||
harness.pads(DPAD_UP);
|
||||
harness.pads();
|
||||
assert.deepEqual(harness.calls, ['nextAudio', 'nextEntry']);
|
||||
|
||||
// L1 with a direction no combo uses still moves between words.
|
||||
harness.pads(L1);
|
||||
harness.pads(L1, 14);
|
||||
assert.deepEqual(harness.calls, ['nextAudio', 'nextEntry', 'moveSelection:-1']);
|
||||
});
|
||||
|
||||
test('L1 and L2 can each hold the same press for different actions', () => {
|
||||
const L2 = 6;
|
||||
const DPAD_LEFT = 14;
|
||||
const harness = createComboHarness({
|
||||
// L2 keeps its default Quit MPV, so this also pins that a combo never quits on release.
|
||||
playCurrentAudio: { kind: 'none' },
|
||||
nextAudio: {
|
||||
kind: 'button',
|
||||
buttonIndex: DPAD_LEFT,
|
||||
hold: { kind: 'button', buttonIndex: L1 },
|
||||
},
|
||||
nextEntry: {
|
||||
kind: 'button',
|
||||
buttonIndex: DPAD_LEFT,
|
||||
hold: { kind: 'button', buttonIndex: L2 },
|
||||
},
|
||||
});
|
||||
|
||||
harness.pads();
|
||||
harness.pads(L1);
|
||||
harness.pads(L1, DPAD_LEFT);
|
||||
harness.pads();
|
||||
harness.pads(L2);
|
||||
harness.pads(L2, DPAD_LEFT);
|
||||
harness.pads();
|
||||
|
||||
assert.deepEqual(harness.calls, ['nextAudio', 'nextEntry']);
|
||||
});
|
||||
|
||||
test('the secondary subtitle hold can be a combo', () => {
|
||||
const harness = createComboHarness({
|
||||
playCurrentAudio: { kind: 'none' },
|
||||
holdSecondarySub: {
|
||||
kind: 'button',
|
||||
buttonIndex: R1,
|
||||
hold: { kind: 'button', buttonIndex: L1 },
|
||||
},
|
||||
nextAudio: { kind: 'none' },
|
||||
});
|
||||
|
||||
harness.pads();
|
||||
harness.pads(R1);
|
||||
harness.pads();
|
||||
harness.pads(L1);
|
||||
harness.pads(L1, R1);
|
||||
harness.pads(L1);
|
||||
|
||||
assert.deepEqual(harness.secondarySubHeld, [true, false]);
|
||||
});
|
||||
|
||||
test('a dialog owning the pad keeps combos from firing and from leaving a tap behind', () => {
|
||||
let suspended = false;
|
||||
const harness = createComboHarness(
|
||||
{ nextAudio: L1_DPAD_RIGHT },
|
||||
{ getSuspended: () => suspended },
|
||||
);
|
||||
|
||||
harness.pads();
|
||||
harness.pads(L1);
|
||||
suspended = true;
|
||||
harness.pads(L1, DPAD_RIGHT);
|
||||
suspended = false;
|
||||
harness.pads(L1, DPAD_RIGHT);
|
||||
harness.pads();
|
||||
|
||||
assert.deepEqual(harness.calls, []);
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
ResolvedControllerDiscreteBinding,
|
||||
} from '../../types';
|
||||
import { resolveControllerConfigForGamepad } from '../controller-profile-config.js';
|
||||
import { createControllerComboTracker } from './controller-combo-tracker.js';
|
||||
|
||||
type ControllerButtonState = {
|
||||
value: number;
|
||||
@@ -228,6 +229,7 @@ function syncHeldActionBlocked(
|
||||
state.initialFired = true;
|
||||
}
|
||||
|
||||
/** Whether the binding's press input is down. A combo's `hold` is checked by the combo tracker. */
|
||||
export function resolveDiscreteBindingPressed(
|
||||
gamepad: GamepadLike,
|
||||
binding: ResolvedControllerDiscreteBinding,
|
||||
@@ -295,8 +297,24 @@ const WATCHING_ACTIONS = [
|
||||
'playNextSubtitle',
|
||||
] as const;
|
||||
|
||||
/** Every action fired by a fresh press; `holdSecondarySub` follows the button instead. */
|
||||
const PRESS_ACTIONS = [
|
||||
'toggleKeyboardOnlyMode',
|
||||
'toggleLookup',
|
||||
'closeLookup',
|
||||
'mineCard',
|
||||
'quitMpv',
|
||||
'toggleCharacterNavigation',
|
||||
...POPUP_ONLY_ACTIONS,
|
||||
...WATCHING_ACTIONS,
|
||||
] as const;
|
||||
|
||||
export function createGamepadController(options: GamepadControllerOptions) {
|
||||
let previousActions = new Map<string, boolean>();
|
||||
const comboTracker = createControllerComboTracker();
|
||||
/** Plain actions on a hold button, pressed and waiting to see whether they were a tap. */
|
||||
const pendingTaps = new Set<string>();
|
||||
const handledThisPoll = new Set<string>();
|
||||
let selectionHold = createHoldState();
|
||||
let jumpHold = createHoldState();
|
||||
let activeGamepadId: string | null = null;
|
||||
@@ -361,14 +379,38 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
config: ResolvedControllerConfig,
|
||||
action: () => void,
|
||||
): void {
|
||||
handledThisPoll.add(actionKey);
|
||||
const isPressed = resolveDiscreteBindingPressed(activeGamepad, binding, config);
|
||||
const wasPressed = previousActions.get(actionKey) ?? false;
|
||||
previousActions.set(actionKey, isPressed);
|
||||
if (!wasPressed && isPressed) {
|
||||
if (isPressed === wasPressed) return;
|
||||
|
||||
if (!isPressed) {
|
||||
if (pendingTaps.delete(actionKey) && !comboTracker.isTapCancelled(binding)) action();
|
||||
return;
|
||||
}
|
||||
if (!comboTracker.matches(binding)) return;
|
||||
if (comboTracker.firesOnRelease(binding)) {
|
||||
pendingTaps.add(actionKey);
|
||||
} else {
|
||||
action();
|
||||
}
|
||||
}
|
||||
|
||||
// The action does not apply this poll. Its edge state keeps following the pad, so a release
|
||||
// is not missed and a held button waits for a fresh press, and a pending tap is dropped.
|
||||
function trackActionWithoutFiring(
|
||||
actionKey: (typeof PRESS_ACTIONS)[number],
|
||||
activeGamepad: GamepadLike,
|
||||
config: ResolvedControllerConfig,
|
||||
): void {
|
||||
previousActions.set(
|
||||
actionKey,
|
||||
resolveDiscreteBindingPressed(activeGamepad, config.bindings[actionKey], config),
|
||||
);
|
||||
if (!handledThisPoll.has(actionKey)) pendingTaps.delete(actionKey);
|
||||
}
|
||||
|
||||
function handleSelectionAxis(value: number, now: number, config: ResolvedControllerConfig): void {
|
||||
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
|
||||
if (Math.abs(value) < activationThreshold) {
|
||||
@@ -410,28 +452,14 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
config: ResolvedControllerConfig,
|
||||
now: number,
|
||||
): void {
|
||||
const discreteActions = [
|
||||
['toggleKeyboardOnlyMode', config.bindings.toggleKeyboardOnlyMode],
|
||||
['toggleLookup', config.bindings.toggleLookup],
|
||||
['closeLookup', config.bindings.closeLookup],
|
||||
['mineCard', config.bindings.mineCard],
|
||||
['quitMpv', config.bindings.quitMpv],
|
||||
['previousAudio', config.bindings.previousAudio],
|
||||
['nextAudio', config.bindings.nextAudio],
|
||||
['playCurrentAudio', config.bindings.playCurrentAudio],
|
||||
['previousEntry', config.bindings.previousEntry],
|
||||
['nextEntry', config.bindings.nextEntry],
|
||||
['toggleCharacterNavigation', config.bindings.toggleCharacterNavigation],
|
||||
...WATCHING_ACTIONS.map((actionKey) => [actionKey, config.bindings[actionKey]] as const),
|
||||
] as const;
|
||||
|
||||
for (const [actionKey, binding] of discreteActions) {
|
||||
previousActions.set(actionKey, resolveDiscreteBindingPressed(activeGamepad, binding, config));
|
||||
for (const actionKey of PRESS_ACTIONS) {
|
||||
trackActionWithoutFiring(actionKey, activeGamepad, config);
|
||||
}
|
||||
|
||||
const axisGamepad = comboTracker.withoutClaimedInputs(activeGamepad);
|
||||
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
|
||||
const selectionValue = resolveAxisBindingValue(
|
||||
activeGamepad,
|
||||
axisGamepad,
|
||||
config.bindings.leftStickHorizontal,
|
||||
config.triggerDeadzone,
|
||||
activationThreshold,
|
||||
@@ -442,7 +470,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
syncHeldActionBlocked(
|
||||
jumpHold,
|
||||
resolveAxisBindingValue(
|
||||
activeGamepad,
|
||||
axisGamepad,
|
||||
config.bindings.rightStickVertical,
|
||||
config.triggerDeadzone,
|
||||
activationThreshold,
|
||||
@@ -463,6 +491,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
const activeGamepad = resolveActiveGamepad(connectedGamepads, config);
|
||||
const previousActiveGamepadId = activeGamepadId;
|
||||
publishState(connectedGamepads, activeGamepad);
|
||||
handledThisPoll.clear();
|
||||
|
||||
if (!activeGamepad) {
|
||||
syncSecondarySubHold(false);
|
||||
@@ -472,6 +501,8 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
// the press that reveals it started after the screen opened, so it should count.
|
||||
screenLayoutWaitingForPad = config.enabled && (options.getScreenLayout?.() ?? null) !== null;
|
||||
previousActions = new Map();
|
||||
comboTracker.reset();
|
||||
pendingTaps.clear();
|
||||
resetHeldAction(selectionHold);
|
||||
resetHeldAction(jumpHold);
|
||||
lastPollAt = null;
|
||||
@@ -481,11 +512,17 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
if (activeGamepad.id !== previousActiveGamepadId) {
|
||||
activeScreenLayout = null;
|
||||
previousActions = new Map();
|
||||
comboTracker.reset();
|
||||
pendingTaps.clear();
|
||||
resetHeldAction(selectionHold);
|
||||
resetHeldAction(jumpHold);
|
||||
}
|
||||
|
||||
const activeConfig = resolveControllerConfigForGamepad(config, activeGamepad.id);
|
||||
comboTracker.update(
|
||||
[...PRESS_ACTIONS, 'holdSecondarySub' as const].map((key) => activeConfig.bindings[key]),
|
||||
(input) => resolveDiscreteBindingPressed(activeGamepad, input, activeConfig),
|
||||
);
|
||||
|
||||
// A screen layout or another window owning the controller pauses every normal action,
|
||||
// including the keyboard-mode toggle and the secondary subtitle hold. Their edge state
|
||||
@@ -521,7 +558,8 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
activeGamepad,
|
||||
activeConfig.bindings.holdSecondarySub,
|
||||
activeConfig,
|
||||
),
|
||||
) &&
|
||||
comboTracker.matches(activeConfig.bindings.holdSecondarySub),
|
||||
);
|
||||
|
||||
// Pause and subtitle navigation also work while simply watching; a blocking modal pauses them.
|
||||
@@ -592,6 +630,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
);
|
||||
|
||||
const activationThreshold = Math.max(activeConfig.stickDeadzone, 0.55);
|
||||
const axisGamepad = comboTracker.withoutClaimedInputs(activeGamepad);
|
||||
|
||||
if (options.getLookupWindowOpen()) {
|
||||
handleActionEdge(
|
||||
@@ -631,7 +670,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
);
|
||||
|
||||
const primaryScroll = resolveAxisBindingValue(
|
||||
activeGamepad,
|
||||
axisGamepad,
|
||||
activeConfig.bindings.leftStickVertical,
|
||||
activeConfig.triggerDeadzone,
|
||||
activeConfig.stickDeadzone,
|
||||
@@ -644,7 +683,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
|
||||
handleJumpAxis(
|
||||
resolveAxisBindingValue(
|
||||
activeGamepad,
|
||||
axisGamepad,
|
||||
activeConfig.bindings.rightStickVertical,
|
||||
activeConfig.triggerDeadzone,
|
||||
activationThreshold,
|
||||
@@ -654,24 +693,14 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
);
|
||||
} else {
|
||||
resetHeldAction(jumpHold);
|
||||
// Popup-only buttons keep tracking the pad while the popup is closed, like blocked
|
||||
// actions do: a release is not missed, and a button held as the popup opens waits for
|
||||
// a fresh press instead of firing at once.
|
||||
for (const actionKey of POPUP_ONLY_ACTIONS) {
|
||||
previousActions.set(
|
||||
actionKey,
|
||||
resolveDiscreteBindingPressed(
|
||||
activeGamepad,
|
||||
activeConfig.bindings[actionKey],
|
||||
activeConfig,
|
||||
),
|
||||
);
|
||||
trackActionWithoutFiring(actionKey, activeGamepad, activeConfig);
|
||||
}
|
||||
}
|
||||
|
||||
handleSelectionAxis(
|
||||
resolveAxisBindingValue(
|
||||
activeGamepad,
|
||||
axisGamepad,
|
||||
activeConfig.bindings.leftStickHorizontal,
|
||||
activeConfig.triggerDeadzone,
|
||||
activationThreshold,
|
||||
|
||||
Reference in new issue
Block a user