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.
109 lines
3.7 KiB
TypeScript
109 lines
3.7 KiB
TypeScript
import type { ControllerRawInput } from '../../types';
|
|
|
|
type ControllerButtonState = {
|
|
value: number;
|
|
pressed?: boolean;
|
|
touched?: boolean;
|
|
};
|
|
|
|
type ControllerSnapshot = {
|
|
axes: readonly number[];
|
|
buttons: readonly ControllerButtonState[];
|
|
};
|
|
|
|
export type ControllerComboCaptureState =
|
|
| { status: 'waiting' }
|
|
| { status: 'holding'; hold: ControllerRawInput }
|
|
| { status: 'done'; binding: ControllerCapturedCombo };
|
|
|
|
export type ControllerCapturedCombo = {
|
|
kind: 'combo';
|
|
press: ControllerRawInput;
|
|
hold: ControllerRawInput | null;
|
|
};
|
|
|
|
function inputKey(input: ControllerRawInput): string {
|
|
return input.kind === 'button'
|
|
? `button:${input.buttonIndex}`
|
|
: `axis:${input.axisIndex}:${input.direction}`;
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* -1, and brushing it must not record that resting position as a press.
|
|
*/
|
|
export function createControllerComboCapture(options: {
|
|
triggerDeadzone: number;
|
|
stickDeadzone: number;
|
|
}) {
|
|
const blocked = new Set<string>();
|
|
const restingAxes = new Set<string>();
|
|
let first: ControllerRawInput | null = null;
|
|
|
|
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 });
|
|
}
|
|
});
|
|
const threshold = Math.max(options.stickDeadzone, 0.55);
|
|
snapshot.axes.forEach((value, axisIndex) => {
|
|
if (typeof value !== 'number' || !Number.isFinite(value) || Math.abs(value) < threshold) {
|
|
return;
|
|
}
|
|
inputs.push({ kind: 'axis', axisIndex, direction: value > 0 ? 'positive' : 'negative' });
|
|
});
|
|
return inputs;
|
|
}
|
|
|
|
function arm(snapshot: ControllerSnapshot): void {
|
|
first = null;
|
|
blocked.clear();
|
|
restingAxes.clear();
|
|
for (const input of activeInputs(snapshot)) {
|
|
(input.kind === 'axis' ? restingAxes : blocked).add(inputKey(input));
|
|
}
|
|
}
|
|
|
|
function poll(snapshot: ControllerSnapshot): ControllerComboCaptureState {
|
|
const active = activeInputs(snapshot).filter((input) => !restingAxes.has(inputKey(input)));
|
|
const activeKeys = new Set(active.map(inputKey));
|
|
for (const key of [...blocked]) {
|
|
if (!activeKeys.has(key)) blocked.delete(key);
|
|
}
|
|
const fresh = active.filter((input) => !blocked.has(inputKey(input)));
|
|
|
|
if (first === null) {
|
|
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 };
|
|
}
|
|
|
|
const firstKey = inputKey(first);
|
|
if (!activeKeys.has(firstKey)) {
|
|
const press = first;
|
|
first = null;
|
|
return { status: 'done', binding: { kind: 'combo', press, hold: null } };
|
|
}
|
|
const second = fresh.find((input) => inputKey(input) !== firstKey);
|
|
if (second) {
|
|
const hold = first;
|
|
first = null;
|
|
return { status: 'done', binding: { kind: 'combo', press: second, hold } };
|
|
}
|
|
return { status: 'holding', hold: first };
|
|
}
|
|
|
|
return { arm, poll };
|
|
}
|