mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-12 04:46:07 -08:00
Controller: layouts for the media timing review and duplicate card dialogs (#313)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
696462a82e
commit
6d0f37ac21
50 files changed
+4555
-46
No files matched your search
@@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createControllerComboCapture } from './controller-combo-capture.js';
|
||||
|
||||
const PRESSED = { value: 1, pressed: true };
|
||||
const RELEASED = { value: 0, pressed: false };
|
||||
|
||||
function snapshot(pressed: number[] = [], axes: number[] = [0, 0, 0, 0]) {
|
||||
return {
|
||||
axes,
|
||||
buttons: Array.from({ length: 17 }, (_, index) =>
|
||||
pressed.includes(index) ? PRESSED : RELEASED,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function createCapture() {
|
||||
return createControllerComboCapture({ triggerDeadzone: 0.5, stickDeadzone: 0.2 });
|
||||
}
|
||||
|
||||
test('combo capture records a single button once it is released', () => {
|
||||
const capture = createCapture();
|
||||
capture.arm(snapshot());
|
||||
|
||||
assert.deepEqual(capture.poll(snapshot()), { status: 'waiting' });
|
||||
assert.deepEqual(capture.poll(snapshot([1])), {
|
||||
status: 'holding',
|
||||
hold: { kind: 'button', buttonIndex: 1 },
|
||||
});
|
||||
assert.deepEqual(capture.poll(snapshot()), {
|
||||
status: 'done',
|
||||
binding: { kind: 'combo', press: { kind: 'button', buttonIndex: 1 }, hold: null },
|
||||
});
|
||||
});
|
||||
|
||||
test('combo capture records hold first + press second', () => {
|
||||
const capture = createCapture();
|
||||
capture.arm(snapshot());
|
||||
|
||||
capture.poll(snapshot([4]));
|
||||
assert.deepEqual(capture.poll(snapshot([4, 15])), {
|
||||
status: 'done',
|
||||
binding: {
|
||||
kind: 'combo',
|
||||
press: { kind: 'button', buttonIndex: 15 },
|
||||
hold: { kind: 'button', buttonIndex: 4 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('combo capture ignores buttons held when Learn starts until they are released', () => {
|
||||
const capture = createCapture();
|
||||
capture.arm(snapshot([0]));
|
||||
|
||||
assert.deepEqual(capture.poll(snapshot([0])), { status: 'waiting' });
|
||||
capture.poll(snapshot());
|
||||
assert.deepEqual(capture.poll(snapshot([0])), {
|
||||
status: 'holding',
|
||||
hold: { kind: 'button', buttonIndex: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
test('combo capture never records a trigger axis resting position', () => {
|
||||
const capture = createCapture();
|
||||
// Some pads rest trigger axes at -1.
|
||||
capture.arm(snapshot([], [0, 0, -1, 0]));
|
||||
|
||||
capture.poll(snapshot([], [0, 0, -0.2, 0]));
|
||||
assert.deepEqual(capture.poll(snapshot([], [0, 0, -1, 0])), { status: 'waiting' });
|
||||
assert.deepEqual(capture.poll(snapshot([], [0, 0, 0.9, 0])), {
|
||||
status: 'holding',
|
||||
hold: { kind: 'axis', axisIndex: 2, direction: 'positive' },
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { ControllerReviewInput, ResolvedControllerReviewBinding } 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: ControllerReviewInput }
|
||||
| { status: 'done'; binding: ResolvedControllerReviewBinding };
|
||||
|
||||
type RawInput = Extract<ControllerReviewInput, { kind: 'button' | 'axis' }>;
|
||||
|
||||
function inputKey(input: RawInput): 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,
|
||||
* 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: RawInput | null = null;
|
||||
|
||||
function activeInputs(snapshot: ControllerSnapshot): RawInput[] {
|
||||
const inputs: RawInput[] = [];
|
||||
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;
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { CORE_DEFAULT_CONFIG } from '../../config/definitions/defaults-core';
|
||||
import {
|
||||
CONTROLLER_FIELD_GROUPING_ACTIONS,
|
||||
CONTROLLER_FIELD_GROUPING_SCREEN,
|
||||
type ControllerFieldGroupingStep,
|
||||
} from '../../shared/controller-field-grouping';
|
||||
import { findControllerScreenBindingIssues } from '../../shared/controller-review';
|
||||
import type { ControllerFieldGroupingAction, ResolvedControllerConfig } from '../../types';
|
||||
import {
|
||||
createControllerComboScreenLayout,
|
||||
formatControllerScreenHints,
|
||||
getControllerReviewInputContext,
|
||||
} from './controller-screen-layout.js';
|
||||
import type { GamepadLike } from './gamepad-controller.js';
|
||||
|
||||
const CONFIG: ResolvedControllerConfig = { ...CORE_DEFAULT_CONFIG.controller, enabled: true };
|
||||
const { repeatDelayMs, repeatIntervalMs } = CONFIG;
|
||||
|
||||
// Standard-layout indices; faces follow config.buttonIndices (Xbox positions by default).
|
||||
const BUTTON = { A: 0, B: 1, X: 2, Y: 3, UP: 12, DOWN: 13, LEFT: 14, RIGHT: 15 };
|
||||
type ButtonName = keyof typeof BUTTON;
|
||||
|
||||
function pad(pressed: ButtonName[] = []): GamepadLike {
|
||||
const buttons = Array.from({ length: 17 }, () => ({ value: 0, pressed: false }));
|
||||
for (const name of pressed) buttons[BUTTON[name]] = { value: 1, pressed: true };
|
||||
return { id: 'pad', index: 0, connected: true, mapping: 'standard', axes: [0, 0, 0, 0], buttons };
|
||||
}
|
||||
|
||||
/** The field grouping layout driving a fake dialog that follows the default flow. */
|
||||
function createFieldGroupingLayout(config = CONFIG) {
|
||||
const actions: ControllerFieldGroupingAction[] = [];
|
||||
const state: { step: ControllerFieldGroupingStep } = { step: 'select' };
|
||||
const layout = createControllerComboScreenLayout({
|
||||
screen: CONTROLLER_FIELD_GROUPING_SCREEN,
|
||||
getBindings: (resolved) => resolved.fieldGroupingBindings,
|
||||
getStep: () => state.step,
|
||||
dispatch: (action) => {
|
||||
actions.push(action);
|
||||
if (action === 'continue') state.step = 'preview';
|
||||
if (action === 'back') state.step = 'select';
|
||||
},
|
||||
});
|
||||
layout.activate(pad(), config, 0, { latchHeld: true });
|
||||
return { layout, actions, state };
|
||||
}
|
||||
|
||||
test('the default duplicate card layout binds every action but preview Cancel, without conflicts', () => {
|
||||
for (const action of CONTROLLER_FIELD_GROUPING_ACTIONS) {
|
||||
assert.equal(
|
||||
CONFIG.fieldGroupingBindings[action.id]?.kind,
|
||||
action.id === 'cancelMerge' ? 'none' : 'combo',
|
||||
action.id,
|
||||
);
|
||||
}
|
||||
for (const mapping of ['standard', ''] as const) {
|
||||
for (const faceButtonLabels of ['xbox', 'nintendo'] as const) {
|
||||
const context = getControllerReviewInputContext({ ...CONFIG, faceButtonLabels }, mapping);
|
||||
assert.deepEqual(
|
||||
findControllerScreenBindingIssues(
|
||||
CONTROLLER_FIELD_GROUPING_SCREEN,
|
||||
CONFIG.fieldGroupingBindings,
|
||||
context,
|
||||
),
|
||||
{},
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('duplicate card hints read like the agreed layout', () => {
|
||||
const hints = formatControllerScreenHints(
|
||||
CONTROLLER_FIELD_GROUPING_SCREEN,
|
||||
CONFIG.fieldGroupingBindings,
|
||||
CONFIG,
|
||||
'standard',
|
||||
);
|
||||
assert.deepEqual(hints, {
|
||||
selectOriginal: '◀',
|
||||
selectNew: '▶',
|
||||
toggleDeleteDuplicate: 'X',
|
||||
continue: 'A',
|
||||
cancel: 'B',
|
||||
confirmMerge: 'A',
|
||||
back: 'B',
|
||||
cancelMerge: null,
|
||||
toggleView: 'Y',
|
||||
scrollUp: '▲',
|
||||
scrollDown: '▼',
|
||||
});
|
||||
});
|
||||
|
||||
test('the D-pad picks the card and X toggles delete on the first step', () => {
|
||||
const { layout, actions } = createFieldGroupingLayout();
|
||||
|
||||
layout.poll(pad(['RIGHT']), CONFIG, 10);
|
||||
layout.poll(pad(['RIGHT']), CONFIG, 2_000);
|
||||
layout.poll(pad(), CONFIG, 2_010);
|
||||
layout.poll(pad(['LEFT']), CONFIG, 2_020);
|
||||
layout.poll(pad(['X']), CONFIG, 2_030);
|
||||
// Up/down scroll only on the preview.
|
||||
layout.poll(pad(['DOWN']), CONFIG, 2_040);
|
||||
|
||||
assert.deepEqual(actions, ['selectNew', 'selectOriginal', 'toggleDeleteDuplicate']);
|
||||
});
|
||||
|
||||
test('B on the preview goes back; holding it does not also cancel', () => {
|
||||
const { layout, actions, state } = createFieldGroupingLayout();
|
||||
|
||||
layout.poll(pad(['A']), CONFIG, 10);
|
||||
assert.equal(state.step, 'preview');
|
||||
// A still held after the step change does not confirm the merge.
|
||||
layout.poll(pad(['A']), CONFIG, 20);
|
||||
layout.poll(pad(), CONFIG, 30);
|
||||
layout.poll(pad(['B']), CONFIG, 40);
|
||||
assert.equal(state.step, 'select');
|
||||
// B held across the step change is not a fresh press on the first step.
|
||||
layout.poll(pad(['B']), CONFIG, 1_000);
|
||||
layout.poll(pad(), CONFIG, 1_010);
|
||||
layout.poll(pad(['B']), CONFIG, 1_020);
|
||||
|
||||
assert.deepEqual(actions, ['continue', 'back', 'cancel']);
|
||||
});
|
||||
|
||||
test('preview scrolling repeats while the D-pad is held', () => {
|
||||
const { layout, actions, state } = createFieldGroupingLayout();
|
||||
state.step = 'preview';
|
||||
|
||||
layout.poll(pad(['DOWN']), CONFIG, 0);
|
||||
layout.poll(pad(['DOWN']), CONFIG, repeatDelayMs - 1);
|
||||
layout.poll(pad(['DOWN']), CONFIG, repeatDelayMs);
|
||||
layout.poll(pad(['DOWN']), CONFIG, repeatDelayMs + repeatIntervalMs);
|
||||
layout.poll(pad(), CONFIG, repeatDelayMs + repeatIntervalMs + 1);
|
||||
layout.poll(pad(['Y']), CONFIG, 5_000);
|
||||
layout.poll(pad(['Y']), CONFIG, 6_000);
|
||||
|
||||
assert.deepEqual(actions, ['scrollDown', 'scrollDown', 'scrollDown', 'toggleView']);
|
||||
});
|
||||
|
||||
test('remapped duplicate card bindings replace the defaults', () => {
|
||||
const config: ResolvedControllerConfig = {
|
||||
...CONFIG,
|
||||
fieldGroupingBindings: {
|
||||
...CONFIG.fieldGroupingBindings,
|
||||
back: { kind: 'none' },
|
||||
cancelMerge: { kind: 'combo', press: { kind: 'face', letter: 'b' }, hold: null },
|
||||
},
|
||||
};
|
||||
const { layout, actions, state } = createFieldGroupingLayout(config);
|
||||
state.step = 'preview';
|
||||
|
||||
layout.poll(pad(['B']), config, 10);
|
||||
|
||||
assert.deepEqual(actions, ['cancelMerge']);
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
compileControllerScreenBindings,
|
||||
formatControllerReviewBinding,
|
||||
type CompiledControllerScreenBinding,
|
||||
type ControllerReviewInputContext,
|
||||
type ControllerReviewInputSource,
|
||||
type ControllerScreenBindings,
|
||||
type ControllerScreenDefinition,
|
||||
} from '../../shared/controller-review';
|
||||
import type { ResolvedControllerConfig } from '../../types';
|
||||
import {
|
||||
createHoldState,
|
||||
resolveDiscreteBindingPressed,
|
||||
resolveDpadHorizontalValue,
|
||||
resolveDpadVerticalValue,
|
||||
shouldFireHeldAction,
|
||||
type ControllerScreenLayout,
|
||||
type GamepadLike,
|
||||
type HoldState,
|
||||
} from './gamepad-controller.js';
|
||||
|
||||
export function getControllerReviewInputContext(
|
||||
config: ResolvedControllerConfig,
|
||||
mapping: string | null | undefined,
|
||||
): ControllerReviewInputContext {
|
||||
return {
|
||||
mapping,
|
||||
faceButtonLabels: config.faceButtonLabels,
|
||||
buttonIndices: config.buttonIndices,
|
||||
};
|
||||
}
|
||||
|
||||
/** Hint text per action of a screen, e.g. `A` or `L1 + ▶`; null when unbound. */
|
||||
export function formatControllerScreenHints<A extends string>(
|
||||
screen: ControllerScreenDefinition<A>,
|
||||
bindings: Partial<ControllerScreenBindings<A>>,
|
||||
config: ResolvedControllerConfig,
|
||||
mapping: string | null | undefined,
|
||||
): Record<A, string | null> {
|
||||
const context = getControllerReviewInputContext(config, mapping);
|
||||
const hints = {} as Record<A, string | null>;
|
||||
for (const action of screen.actions) {
|
||||
const binding = bindings[action.id];
|
||||
hints[action.id] =
|
||||
binding && binding.kind === 'combo' ? formatControllerReviewBinding(binding, context) : null;
|
||||
}
|
||||
return hints;
|
||||
}
|
||||
|
||||
function isSourcePressed(
|
||||
source: ControllerReviewInputSource,
|
||||
gamepad: GamepadLike,
|
||||
config: ResolvedControllerConfig,
|
||||
): boolean {
|
||||
if (source.kind === 'dpad') {
|
||||
if (source.direction === 'left' || source.direction === 'right') {
|
||||
const value = resolveDpadHorizontalValue(gamepad, config.triggerDeadzone);
|
||||
return source.direction === 'left' ? value < 0 : value > 0;
|
||||
}
|
||||
const value = resolveDpadVerticalValue(gamepad, config.triggerDeadzone);
|
||||
return source.direction === 'up' ? value < 0 : value > 0;
|
||||
}
|
||||
return resolveDiscreteBindingPressed(gamepad, source, config);
|
||||
}
|
||||
|
||||
type PressChannel<A extends string> = {
|
||||
action: A | null;
|
||||
repeat: boolean;
|
||||
/** Hold buttons that were down when this press started. */
|
||||
modifiers: string;
|
||||
/** Set when the hold buttons changed mid-press; the press must be released first. */
|
||||
latched: boolean;
|
||||
hold: HoldState;
|
||||
};
|
||||
|
||||
type ScreenFrame<A extends string> = {
|
||||
compiled: CompiledControllerScreenBinding<A>[];
|
||||
pressed: Set<string>;
|
||||
holdKeys: Set<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Picks the action for a fresh press: with one hold button down, the binding that uses it;
|
||||
* with none, the plain binding. Two hold buttons at once is ambiguous and matches nothing.
|
||||
*/
|
||||
function matchAction<A extends string>(
|
||||
compiled: CompiledControllerScreenBinding<A>[],
|
||||
pressKey: string,
|
||||
modifiers: string[],
|
||||
): A | null {
|
||||
if (modifiers.length > 1) return null;
|
||||
const holdKey = modifiers[0] ?? null;
|
||||
return (
|
||||
compiled.find((entry) => entry.pressKey === pressKey && entry.holdKey === holdKey)?.action ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Controller layout for a dialog described by a screen definition: per step, press-or-combo
|
||||
* bindings with optional repeat. Actions are dispatched; the dialog decides what they do.
|
||||
*/
|
||||
export function createControllerComboScreenLayout<A extends string, S extends string>(options: {
|
||||
screen: ControllerScreenDefinition<A, S>;
|
||||
getBindings: (config: ResolvedControllerConfig) => Partial<ControllerScreenBindings<A>>;
|
||||
getStep: () => S;
|
||||
dispatch: (action: A) => void;
|
||||
}): ControllerScreenLayout {
|
||||
const { screen } = options;
|
||||
const channels = new Map<string, PressChannel<A>>();
|
||||
const repeatByAction = new Map(screen.actions.map((action) => [action.id, action.repeat]));
|
||||
|
||||
function readFrame(gamepad: GamepadLike, config: ResolvedControllerConfig): ScreenFrame<A> {
|
||||
const context = getControllerReviewInputContext(config, gamepad.mapping);
|
||||
const step = options.getStep();
|
||||
const bindings = options.getBindings(config);
|
||||
const compiled = compileControllerScreenBindings(screen, bindings, step, context);
|
||||
// Track inputs from every step so a press held across a step change is not new.
|
||||
const sources = new Map<string, ControllerReviewInputSource>();
|
||||
for (const { id: anyStep } of screen.steps) {
|
||||
const entries =
|
||||
anyStep === step
|
||||
? compiled
|
||||
: compileControllerScreenBindings(screen, bindings, anyStep, context);
|
||||
for (const entry of entries) {
|
||||
sources.set(entry.pressKey, entry.press);
|
||||
if (entry.hold && entry.holdKey) sources.set(entry.holdKey, entry.hold);
|
||||
}
|
||||
}
|
||||
const pressed = new Set<string>();
|
||||
for (const [key, source] of sources) {
|
||||
if (isSourcePressed(source, gamepad, config)) pressed.add(key);
|
||||
}
|
||||
const holdKeys = new Set<string>();
|
||||
for (const entry of compiled) {
|
||||
if (entry.holdKey) holdKeys.add(entry.holdKey);
|
||||
}
|
||||
return { compiled, pressed, holdKeys };
|
||||
}
|
||||
|
||||
function heldModifiers(frame: ScreenFrame<A>, exceptKey: string): string[] {
|
||||
return [...frame.holdKeys].filter((key) => key !== exceptKey && frame.pressed.has(key)).sort();
|
||||
}
|
||||
|
||||
function poll(gamepad: GamepadLike, config: ResolvedControllerConfig, now: number): void {
|
||||
const frame = readFrame(gamepad, config);
|
||||
|
||||
for (const key of [...channels.keys()]) {
|
||||
if (!frame.pressed.has(key)) channels.delete(key);
|
||||
}
|
||||
|
||||
for (const key of frame.pressed) {
|
||||
const modifiers = heldModifiers(frame, key);
|
||||
const signature = modifiers.join('|');
|
||||
const channel = channels.get(key);
|
||||
|
||||
if (!channel) {
|
||||
const action = matchAction(frame.compiled, key, modifiers);
|
||||
const next: PressChannel<A> = {
|
||||
action,
|
||||
repeat: action ? (repeatByAction.get(action) ?? false) : false,
|
||||
modifiers: signature,
|
||||
latched: false,
|
||||
hold: createHoldState(),
|
||||
};
|
||||
channels.set(key, next);
|
||||
if (action) {
|
||||
shouldFireHeldAction(next.hold, now, config.repeatDelayMs, config.repeatIntervalMs);
|
||||
options.dispatch(action);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (channel.latched || channel.action === null) continue;
|
||||
if (channel.modifiers !== signature) {
|
||||
// Letting go of L1 a moment before the D-pad must not fire the plain D-pad action.
|
||||
channel.latched = true;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
channel.repeat &&
|
||||
shouldFireHeldAction(channel.hold, now, config.repeatDelayMs, config.repeatIntervalMs)
|
||||
) {
|
||||
options.dispatch(channel.action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function latchHeld(gamepad: GamepadLike, config: ResolvedControllerConfig): void {
|
||||
channels.clear();
|
||||
for (const key of readFrame(gamepad, config).pressed) {
|
||||
channels.set(key, {
|
||||
action: null,
|
||||
repeat: false,
|
||||
modifiers: '',
|
||||
latched: true,
|
||||
hold: createHoldState(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activate(gamepad, config, now, { latchHeld: shouldLatch }) {
|
||||
if (shouldLatch) {
|
||||
latchHeld(gamepad, config);
|
||||
return;
|
||||
}
|
||||
channels.clear();
|
||||
poll(gamepad, config, now);
|
||||
},
|
||||
poll,
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import test from 'node:test';
|
||||
|
||||
import { CORE_DEFAULT_CONFIG } from '../../config/definitions/defaults-core';
|
||||
import type { ControllerRuntimeSnapshot, ResolvedControllerConfig } from '../../types';
|
||||
import { createGamepadController } from './gamepad-controller.js';
|
||||
import { createGamepadController, type ControllerScreenLayout } from './gamepad-controller.js';
|
||||
|
||||
type ControllerOptions = Parameters<typeof createGamepadController>[0];
|
||||
type Bindings = ResolvedControllerConfig['bindings'];
|
||||
@@ -290,6 +290,8 @@ test('gamepad controller uses active controller profile bindings before global b
|
||||
label: 'Profile Pad',
|
||||
buttonIndices: globalConfig.buttonIndices,
|
||||
bindings: { ...globalConfig.bindings, toggleLookup: { kind: 'button', buttonIndex: 11 } },
|
||||
reviewBindings: globalConfig.reviewBindings,
|
||||
fieldGroupingBindings: globalConfig.fieldGroupingBindings,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -659,3 +661,126 @@ test('gamepad controller requires keyboard-only mode for popup entry navigation'
|
||||
|
||||
assert.deepEqual(harness.calls, []);
|
||||
});
|
||||
|
||||
function createRecordingScreenLayout() {
|
||||
const events: string[] = [];
|
||||
const layout: ControllerScreenLayout = {
|
||||
activate: (gamepad, _config, _now, { latchHeld }) => {
|
||||
events.push(`activate:${gamepad.id}:${latchHeld ? 'latch' : 'fresh'}`);
|
||||
},
|
||||
poll: (_gamepad, _config, now) => {
|
||||
events.push(`poll:${now}`);
|
||||
},
|
||||
};
|
||||
return { layout, events };
|
||||
}
|
||||
|
||||
test('gamepad controller hands every input to an open screen layout', () => {
|
||||
let screenOpen = true;
|
||||
const { layout, events } = createRecordingScreenLayout();
|
||||
const harness = createHarness({
|
||||
// A (lookup), Y (keyboard mode) and R2 (secondary hold) are all pressed.
|
||||
gamepads: [createGamepad('pad-1', { buttons: { 0: PRESSED, 3: PRESSED, 7: PRESSED } })],
|
||||
config: createControllerConfig({ bindings: HOLD_ON_R2 }),
|
||||
deps: { getScreenLayout: () => (screenOpen ? layout : null) },
|
||||
});
|
||||
|
||||
harness.poll(0, 16, 32);
|
||||
assert.deepEqual(events, ['activate:pad-1:latch', 'poll:16', 'poll:32']);
|
||||
assert.deepEqual(harness.calls, []);
|
||||
assert.deepEqual(harness.secondarySubHeld, []);
|
||||
|
||||
// Buttons still held when the screen closes do not fire their normal actions...
|
||||
screenOpen = false;
|
||||
harness.poll(48);
|
||||
assert.deepEqual(harness.calls, []);
|
||||
// ...but a level-triggered hold resumes, since it reflects what is held right now.
|
||||
assert.deepEqual(harness.secondarySubHeld, [true]);
|
||||
});
|
||||
|
||||
test('gamepad controller re-activates the screen layout for each new session', () => {
|
||||
let screenOpen = true;
|
||||
const { layout, events } = createRecordingScreenLayout();
|
||||
const harness = createHarness({
|
||||
gamepads: [createGamepad('pad-1')],
|
||||
deps: { getScreenLayout: () => (screenOpen ? layout : null) },
|
||||
});
|
||||
|
||||
harness.poll(0, 16);
|
||||
screenOpen = false;
|
||||
harness.poll(32);
|
||||
screenOpen = true;
|
||||
harness.poll(48, 64);
|
||||
|
||||
assert.deepEqual(events, ['activate:pad-1:latch', 'poll:16', 'activate:pad-1:latch', 'poll:64']);
|
||||
});
|
||||
|
||||
test('gamepad controller ignores the screen layout while controller support is disabled', () => {
|
||||
const { layout, events } = createRecordingScreenLayout();
|
||||
const harness = createHarness({
|
||||
gamepads: [createGamepad('pad-1', { buttons: { 0: PRESSED } })],
|
||||
config: createControllerConfig({ enabled: false }),
|
||||
deps: { getScreenLayout: () => layout },
|
||||
});
|
||||
|
||||
harness.poll(0, 16);
|
||||
|
||||
assert.deepEqual(events, []);
|
||||
});
|
||||
|
||||
test('gamepad controller stands down entirely while another window owns the controller', () => {
|
||||
let suspended = true;
|
||||
let r2: TestButton = PRESSED;
|
||||
const harness = createHarness({
|
||||
gamepads: () => [createGamepad('pad-1', { buttons: { 0: PRESSED, 3: PRESSED, 7: r2 } })],
|
||||
config: createControllerConfig({ bindings: HOLD_ON_R2 }),
|
||||
});
|
||||
harness.poll(0);
|
||||
assert.deepEqual(harness.secondarySubHeld, [true]);
|
||||
|
||||
const suspendedHarness = createHarness({
|
||||
gamepads: () => [createGamepad('pad-1', { buttons: { 0: PRESSED, 3: PRESSED, 7: r2 } })],
|
||||
config: createControllerConfig({ bindings: HOLD_ON_R2 }),
|
||||
deps: { getSuspended: () => suspended },
|
||||
});
|
||||
suspendedHarness.poll(0, 16);
|
||||
// Not even the keyboard-mode toggle (Y) fires, and the hold stays released.
|
||||
assert.deepEqual(suspendedHarness.calls, []);
|
||||
assert.deepEqual(suspendedHarness.secondarySubHeld, []);
|
||||
|
||||
suspended = false;
|
||||
r2 = RELEASED;
|
||||
suspendedHarness.poll(32);
|
||||
assert.deepEqual(suspendedHarness.calls, []);
|
||||
});
|
||||
|
||||
test('gamepad controller counts the press that reveals a pad to an open screen', () => {
|
||||
let visible = false;
|
||||
const { layout, events } = createRecordingScreenLayout();
|
||||
const harness = createHarness({
|
||||
// Until a button is pressed in a fresh window, the browser reports no pads at all.
|
||||
gamepads: () => (visible ? [createGamepad('pad-1', { buttons: { 0: PRESSED } })] : []),
|
||||
deps: { getScreenLayout: () => layout },
|
||||
});
|
||||
|
||||
harness.poll(0, 16);
|
||||
visible = true;
|
||||
harness.poll(32, 48);
|
||||
|
||||
assert.deepEqual(events, ['activate:pad-1:fresh', 'poll:48']);
|
||||
});
|
||||
|
||||
test('gamepad controller still ignores a button held when the screen opened', () => {
|
||||
let screenOpen = false;
|
||||
const { layout, events } = createRecordingScreenLayout();
|
||||
const harness = createHarness({
|
||||
gamepads: [createGamepad('pad-1', { buttons: { 2: PRESSED } })],
|
||||
deps: { getScreenLayout: () => (screenOpen ? layout : null) },
|
||||
});
|
||||
|
||||
harness.poll(0);
|
||||
screenOpen = true;
|
||||
harness.poll(16);
|
||||
|
||||
assert.deepEqual(events, ['activate:pad-1:latch']);
|
||||
});
|
||||
@@ -13,7 +13,7 @@ type ControllerButtonState = {
|
||||
touched?: boolean;
|
||||
};
|
||||
|
||||
type GamepadLike = {
|
||||
export type GamepadLike = {
|
||||
id: string;
|
||||
index: number;
|
||||
connected: boolean;
|
||||
@@ -22,6 +22,22 @@ type GamepadLike = {
|
||||
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;
|
||||
@@ -44,9 +60,13 @@ type GamepadControllerOptions = {
|
||||
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;
|
||||
};
|
||||
|
||||
type HoldState = {
|
||||
export type HoldState = {
|
||||
repeatStarted: boolean;
|
||||
direction: -1 | 1 | null;
|
||||
lastFireAt: number;
|
||||
@@ -65,7 +85,7 @@ const DPAD_AXIS_INDEX = {
|
||||
vertical: 7,
|
||||
} as const;
|
||||
|
||||
function normalizeRawButtonState(
|
||||
export function normalizeRawButtonState(
|
||||
button: ControllerButtonState | undefined,
|
||||
triggerDeadzone: number,
|
||||
): boolean {
|
||||
@@ -114,7 +134,7 @@ function resolveDpadAxisValue(gamepad: GamepadLike, axisIndex: number): number {
|
||||
return Math.sign(value);
|
||||
}
|
||||
|
||||
function resolveDpadHorizontalValue(gamepad: GamepadLike, triggerDeadzone: number): number {
|
||||
export function resolveDpadHorizontalValue(gamepad: GamepadLike, triggerDeadzone: number): number {
|
||||
const axisValue = resolveDpadAxisValue(gamepad, DPAD_AXIS_INDEX.horizontal);
|
||||
if (axisValue !== 0) {
|
||||
return axisValue;
|
||||
@@ -127,7 +147,7 @@ function resolveDpadHorizontalValue(gamepad: GamepadLike, triggerDeadzone: numbe
|
||||
);
|
||||
}
|
||||
|
||||
function resolveDpadVerticalValue(gamepad: GamepadLike, triggerDeadzone: number): number {
|
||||
export function resolveDpadVerticalValue(gamepad: GamepadLike, triggerDeadzone: number): number {
|
||||
const axisValue = resolveDpadAxisValue(gamepad, DPAD_AXIS_INDEX.vertical);
|
||||
if (axisValue !== 0) {
|
||||
return axisValue;
|
||||
@@ -141,7 +161,7 @@ function resolveConnectedGamepads(gamepads: Array<GamepadLike | null>): GamepadL
|
||||
.sort((left, right) => left.index - right.index);
|
||||
}
|
||||
|
||||
function createHoldState(): HoldState {
|
||||
export function createHoldState(): HoldState {
|
||||
return {
|
||||
repeatStarted: false,
|
||||
direction: null,
|
||||
@@ -150,7 +170,7 @@ function createHoldState(): HoldState {
|
||||
};
|
||||
}
|
||||
|
||||
function shouldFireHeldAction(
|
||||
export function shouldFireHeldAction(
|
||||
state: HoldState,
|
||||
now: number,
|
||||
repeatDelayMs: number,
|
||||
@@ -173,7 +193,7 @@ function shouldFireHeldAction(
|
||||
return true;
|
||||
}
|
||||
|
||||
function resetHeldAction(state: HoldState): void {
|
||||
export function resetHeldAction(state: HoldState): void {
|
||||
state.repeatStarted = false;
|
||||
state.direction = null;
|
||||
state.lastFireAt = 0;
|
||||
@@ -198,7 +218,7 @@ function syncHeldActionBlocked(
|
||||
state.initialFired = true;
|
||||
}
|
||||
|
||||
function resolveDiscreteBindingPressed(
|
||||
export function resolveDiscreteBindingPressed(
|
||||
gamepad: GamepadLike,
|
||||
binding: ResolvedControllerDiscreteBinding,
|
||||
config: ResolvedControllerConfig,
|
||||
@@ -263,6 +283,8 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
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());
|
||||
@@ -423,6 +445,11 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
|
||||
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();
|
||||
resetHeldAction(selectionHold);
|
||||
resetHeldAction(jumpHold);
|
||||
@@ -431,6 +458,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
}
|
||||
|
||||
if (activeGamepad.id !== previousActiveGamepadId) {
|
||||
activeScreenLayout = null;
|
||||
previousActions = new Map();
|
||||
resetHeldAction(selectionHold);
|
||||
resetHeldAction(jumpHold);
|
||||
@@ -438,6 +466,31 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
|
||||
const activeConfig = resolveControllerConfigForGamepad(config, activeGamepad.id);
|
||||
|
||||
// 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(
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { CORE_DEFAULT_CONFIG } from '../../config/definitions/defaults-core';
|
||||
import {
|
||||
CONTROLLER_REVIEW_ACTIONS,
|
||||
findControllerReviewBindingIssues,
|
||||
} from '../../shared/controller-review';
|
||||
import type { ResolvedControllerConfig } from '../../types';
|
||||
import type { GamepadLike } from './gamepad-controller.js';
|
||||
import {
|
||||
createMediaTimingReviewControllerLayout,
|
||||
formatMediaTimingReviewControllerHints,
|
||||
getControllerReviewInputContext,
|
||||
type MediaTimingReviewControllerAction,
|
||||
type MediaTimingReviewStep,
|
||||
} from './media-timing-review-controller.js';
|
||||
|
||||
const CONFIG: ResolvedControllerConfig = { ...CORE_DEFAULT_CONFIG.controller, enabled: true };
|
||||
const { repeatDelayMs, repeatIntervalMs } = CONFIG;
|
||||
|
||||
// Standard-layout indices; the layout reads them from config.buttonIndices.
|
||||
const BUTTON = { A: 0, B: 1, X: 2, Y: 3, L1: 4, R1: 5, UP: 12, DOWN: 13, LEFT: 14, RIGHT: 15 };
|
||||
type ButtonName = keyof typeof BUTTON;
|
||||
|
||||
function pad(pressed: ButtonName[] = [], axes: number[] = [0, 0, 0, 0]): GamepadLike {
|
||||
const buttons = Array.from({ length: 17 }, () => ({ value: 0, pressed: false }));
|
||||
for (const name of pressed) buttons[BUTTON[name]] = { value: 1, pressed: true };
|
||||
return { id: 'pad', index: 0, connected: true, mapping: 'standard', axes, buttons };
|
||||
}
|
||||
|
||||
function createLayout(step: MediaTimingReviewStep = 'editor') {
|
||||
const actions: MediaTimingReviewControllerAction[] = [];
|
||||
const state = { step };
|
||||
const layout = createMediaTimingReviewControllerLayout({
|
||||
getStep: () => state.step,
|
||||
dispatch: (action) => actions.push(action),
|
||||
});
|
||||
layout.activate(pad(), CONFIG, 0, { latchHeld: true });
|
||||
return { layout, actions, state };
|
||||
}
|
||||
|
||||
test('the default review layout binds all 21 actions without conflicts', () => {
|
||||
assert.equal(CONTROLLER_REVIEW_ACTIONS.length, 21);
|
||||
for (const action of CONTROLLER_REVIEW_ACTIONS) {
|
||||
assert.equal(CONFIG.reviewBindings[action.id]?.kind, 'combo', action.id);
|
||||
}
|
||||
for (const mapping of ['standard', ''] as const) {
|
||||
for (const faceButtonLabels of ['xbox', 'nintendo'] as const) {
|
||||
const context = getControllerReviewInputContext({ ...CONFIG, faceButtonLabels }, mapping);
|
||||
assert.deepEqual(findControllerReviewBindingIssues(CONFIG.reviewBindings, context), {});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('controller hints read like the documented layout', () => {
|
||||
const hints = formatMediaTimingReviewControllerHints(CONFIG, 'standard');
|
||||
assert.equal(hints.confirm, 'A');
|
||||
assert.equal(hints.showEarlier, '\u25c0');
|
||||
assert.equal(hints.startForward, 'L1 + \u25b6');
|
||||
assert.equal(hints.nextLineAdd, 'R1 + \u25b2');
|
||||
assert.equal(hints.frameReset, 'L1 + R1');
|
||||
});
|
||||
|
||||
test('face buttons press the editor buttons once per press', () => {
|
||||
const { layout, actions } = createLayout();
|
||||
|
||||
layout.poll(pad(['A']), CONFIG, 10);
|
||||
layout.poll(pad(['A']), CONFIG, 900);
|
||||
layout.poll(pad(), CONFIG, 910);
|
||||
layout.poll(pad(['B', 'X', 'Y']), CONFIG, 920);
|
||||
|
||||
assert.deepEqual(actions, ['confirm', 'cancel', 'play', 'resetTiming']);
|
||||
});
|
||||
|
||||
test('face buttons press the cancel-step buttons on that step', () => {
|
||||
const { layout, actions } = createLayout('cancel');
|
||||
|
||||
layout.poll(pad(['A', 'B', 'X', 'Y']), CONFIG, 10);
|
||||
|
||||
assert.deepEqual(actions, ['useOriginal', 'keepEditing', 'skipMedia', 'discard']);
|
||||
});
|
||||
|
||||
test('buttons already held when the review opens wait for a fresh press', () => {
|
||||
const actions: MediaTimingReviewControllerAction[] = [];
|
||||
const layout = createMediaTimingReviewControllerLayout({
|
||||
getStep: () => 'editor',
|
||||
dispatch: (action) => actions.push(action),
|
||||
});
|
||||
|
||||
// X mined the card that opened this review; it must not also start the preview.
|
||||
layout.activate(pad(['X', 'RIGHT']), CONFIG, 0, { latchHeld: true });
|
||||
layout.poll(pad(['X', 'RIGHT']), CONFIG, 1_000);
|
||||
assert.deepEqual(actions, []);
|
||||
|
||||
layout.poll(pad(), CONFIG, 1_010);
|
||||
layout.poll(pad(['X', 'RIGHT']), CONFIG, 1_020);
|
||||
assert.deepEqual(actions, ['play', 'showLater']);
|
||||
});
|
||||
|
||||
test('D-pad alone moves the timeline and steps screenshot frames', () => {
|
||||
const { layout, actions } = createLayout();
|
||||
|
||||
for (const name of ['LEFT', 'RIGHT', 'UP', 'DOWN'] as const) {
|
||||
layout.poll(pad([name]), CONFIG, 10);
|
||||
layout.poll(pad(), CONFIG, 20);
|
||||
}
|
||||
|
||||
assert.deepEqual(actions, ['showEarlier', 'showLater', 'framePrevious', 'frameNext']);
|
||||
});
|
||||
|
||||
test('L1 combos adjust the start and previous lines, R1 combos the end and next lines', () => {
|
||||
const { layout, actions } = createLayout();
|
||||
const press = (names: ButtonName[]) => {
|
||||
layout.poll(pad(names), CONFIG, 10);
|
||||
layout.poll(pad(), CONFIG, 20);
|
||||
};
|
||||
|
||||
press(['L1', 'LEFT']);
|
||||
press(['L1', 'RIGHT']);
|
||||
press(['L1', 'UP']);
|
||||
press(['L1', 'DOWN']);
|
||||
press(['R1', 'LEFT']);
|
||||
press(['R1', 'RIGHT']);
|
||||
press(['R1', 'UP']);
|
||||
press(['R1', 'DOWN']);
|
||||
|
||||
assert.deepEqual(actions, [
|
||||
'startBack',
|
||||
'startForward',
|
||||
'previousLineAdd',
|
||||
'previousLineRemove',
|
||||
'endBack',
|
||||
'endForward',
|
||||
'nextLineAdd',
|
||||
'nextLineRemove',
|
||||
]);
|
||||
});
|
||||
|
||||
test('holding a D-pad combo repeats after the configured delay', () => {
|
||||
const { layout, actions } = createLayout();
|
||||
const hold = (now: number) => layout.poll(pad(['L1', 'RIGHT']), CONFIG, now);
|
||||
|
||||
hold(1_000);
|
||||
hold(1_000 + repeatDelayMs - 1);
|
||||
hold(1_000 + repeatDelayMs);
|
||||
hold(1_000 + repeatDelayMs + repeatIntervalMs - 1);
|
||||
hold(1_000 + repeatDelayMs + repeatIntervalMs);
|
||||
|
||||
assert.deepEqual(actions, ['startForward', 'startForward', 'startForward']);
|
||||
});
|
||||
|
||||
test('letting go of L1 before the D-pad does not fire the plain D-pad action', () => {
|
||||
const { layout, actions } = createLayout();
|
||||
|
||||
layout.poll(pad(['L1', 'RIGHT']), CONFIG, 10);
|
||||
layout.poll(pad(['RIGHT']), CONFIG, 20);
|
||||
layout.poll(pad(['RIGHT']), CONFIG, 2_000);
|
||||
assert.deepEqual(actions, ['startForward']);
|
||||
|
||||
layout.poll(pad(), CONFIG, 2_010);
|
||||
layout.poll(pad(['RIGHT']), CONFIG, 2_020);
|
||||
assert.deepEqual(actions, ['startForward', 'showLater']);
|
||||
});
|
||||
|
||||
test('pressing L1 and R1 together resets the screenshot frame once', () => {
|
||||
const { layout, actions } = createLayout();
|
||||
|
||||
layout.poll(pad(['L1']), CONFIG, 10);
|
||||
layout.poll(pad(['L1', 'R1']), CONFIG, 20);
|
||||
layout.poll(pad(['L1', 'R1', 'RIGHT']), CONFIG, 30);
|
||||
layout.poll(pad(['L1', 'R1', 'RIGHT']), CONFIG, 2_000);
|
||||
|
||||
assert.deepEqual(actions, ['frameReset']);
|
||||
});
|
||||
|
||||
test('D-pads reported as axes (non-standard pads) drive the same layout', () => {
|
||||
const { layout, actions } = createLayout();
|
||||
const axes = (horizontal: number, vertical: number) => [0, 0, 0, 0, 0, 0, horizontal, vertical];
|
||||
|
||||
layout.poll(pad(['R1'], axes(-1, 0)), CONFIG, 10);
|
||||
layout.poll(pad([], axes(0, 0)), CONFIG, 20);
|
||||
layout.poll(pad([], axes(0, 1)), CONFIG, 30);
|
||||
|
||||
assert.deepEqual(actions, ['endBack', 'frameNext']);
|
||||
});
|
||||
|
||||
test('Nintendo-style pads press the button labelled A to confirm, wherever it sits', () => {
|
||||
const nintendo: ResolvedControllerConfig = { ...CONFIG, faceButtonLabels: 'nintendo' };
|
||||
const actions: MediaTimingReviewControllerAction[] = [];
|
||||
const layout = createMediaTimingReviewControllerLayout({
|
||||
getStep: () => 'editor',
|
||||
dispatch: (action) => actions.push(action),
|
||||
});
|
||||
layout.activate(pad(), nintendo, 0, { latchHeld: true });
|
||||
|
||||
// Positions, not letters: on these pads A is the right button (index 1), B the bottom
|
||||
// one (0), X the top one (3) and Y the left one (2).
|
||||
for (const position of ['B', 'A', 'Y', 'X'] as const) {
|
||||
layout.poll(pad([position]), nintendo, 10);
|
||||
layout.poll(pad(), nintendo, 20);
|
||||
}
|
||||
|
||||
assert.deepEqual(actions, ['confirm', 'cancel', 'play', 'resetTiming']);
|
||||
});
|
||||
|
||||
test('a press that first reveals the pad to a fresh dialog window counts', () => {
|
||||
const actions: MediaTimingReviewControllerAction[] = [];
|
||||
const layout = createMediaTimingReviewControllerLayout({
|
||||
getStep: () => 'editor',
|
||||
dispatch: (action) => actions.push(action),
|
||||
});
|
||||
|
||||
layout.activate(pad(['A']), CONFIG, 0, { latchHeld: false });
|
||||
layout.poll(pad(['A']), CONFIG, 16);
|
||||
|
||||
assert.deepEqual(actions, ['confirm']);
|
||||
});
|
||||
|
||||
function withReviewBindings(
|
||||
overrides: Partial<ResolvedControllerConfig['reviewBindings']>,
|
||||
): ResolvedControllerConfig {
|
||||
return { ...CONFIG, reviewBindings: { ...CONFIG.reviewBindings, ...overrides } };
|
||||
}
|
||||
|
||||
test('remapped combos use any hold button and leave plain presses alone', () => {
|
||||
const config = withReviewBindings({
|
||||
// Hold L2 (raw button 6 on standard pads) and press A.
|
||||
startForward: {
|
||||
kind: 'combo',
|
||||
press: { kind: 'face', letter: 'a' },
|
||||
hold: { kind: 'button', buttonIndex: 6 },
|
||||
},
|
||||
});
|
||||
const actions: MediaTimingReviewControllerAction[] = [];
|
||||
const layout = createMediaTimingReviewControllerLayout({
|
||||
getStep: () => 'editor',
|
||||
dispatch: (action) => actions.push(action),
|
||||
});
|
||||
const press = (indices: number[], now: number) => {
|
||||
const buttons = Array.from({ length: 17 }, (_, index) => ({
|
||||
value: indices.includes(index) ? 1 : 0,
|
||||
pressed: indices.includes(index),
|
||||
}));
|
||||
layout.poll({ ...pad(), buttons }, config, now);
|
||||
};
|
||||
layout.activate(pad(), config, 0, { latchHeld: true });
|
||||
|
||||
press([6], 10);
|
||||
press([6, 0], 20);
|
||||
press([], 30);
|
||||
press([0], 40);
|
||||
|
||||
assert.deepEqual(actions, ['startForward', 'confirm']);
|
||||
});
|
||||
|
||||
test('unbound review actions never fire', () => {
|
||||
const config = withReviewBindings({ confirm: { kind: 'none' } });
|
||||
const actions: MediaTimingReviewControllerAction[] = [];
|
||||
const layout = createMediaTimingReviewControllerLayout({
|
||||
getStep: () => 'editor',
|
||||
dispatch: (action) => actions.push(action),
|
||||
});
|
||||
layout.activate(pad(), config, 0, { latchHeld: true });
|
||||
|
||||
layout.poll(pad(['A']), config, 10);
|
||||
|
||||
assert.deepEqual(actions, []);
|
||||
});
|
||||
|
||||
test('repeat follows the action, not the button it is on', () => {
|
||||
const config = withReviewBindings({
|
||||
startForward: { kind: 'combo', press: { kind: 'face', letter: 'b' }, hold: null },
|
||||
cancel: { kind: 'combo', press: { kind: 'dpad', direction: 'right' }, hold: null },
|
||||
showLater: { kind: 'none' },
|
||||
});
|
||||
const actions: MediaTimingReviewControllerAction[] = [];
|
||||
const layout = createMediaTimingReviewControllerLayout({
|
||||
getStep: () => 'editor',
|
||||
dispatch: (action) => actions.push(action),
|
||||
});
|
||||
layout.activate(pad(), config, 0, { latchHeld: true });
|
||||
|
||||
for (const now of [10, 10 + repeatDelayMs, 10 + repeatDelayMs + repeatIntervalMs]) {
|
||||
layout.poll(pad(['B', 'RIGHT']), config, now);
|
||||
}
|
||||
|
||||
// Both fire on the first press; only startForward repeats.
|
||||
assert.deepEqual(actions, ['cancel', 'startForward', 'startForward', 'startForward']);
|
||||
});
|
||||
|
||||
test('conflicts are reported, including a learned D-pad button that matches a default', () => {
|
||||
const config = withReviewBindings({
|
||||
// A learned raw D-pad left (button 14) is the same input as the default "D-pad ◀".
|
||||
showLater: { kind: 'combo', press: { kind: 'button', buttonIndex: 14 }, hold: null },
|
||||
// R1 alone, while other actions hold R1.
|
||||
play: { kind: 'combo', press: { kind: 'button', buttonIndex: 5 }, hold: null },
|
||||
});
|
||||
const issues = findControllerReviewBindingIssues(
|
||||
config.reviewBindings,
|
||||
getControllerReviewInputContext(config, 'standard'),
|
||||
);
|
||||
|
||||
assert.equal(issues.showLater, 'Same as Earlier −2s');
|
||||
assert.equal(issues.showEarlier, 'Same as Later +2s');
|
||||
assert.equal(issues.play, 'R1 is also a hold button on this step');
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
CONTROLLER_REVIEW_SCREEN,
|
||||
type ControllerReviewStep,
|
||||
} from '../../shared/controller-review';
|
||||
import type { ControllerReviewAction, ResolvedControllerConfig } from '../../types';
|
||||
import {
|
||||
createControllerComboScreenLayout,
|
||||
formatControllerScreenHints,
|
||||
} from './controller-screen-layout.js';
|
||||
import type { ControllerScreenLayout } from './gamepad-controller.js';
|
||||
|
||||
export { getControllerReviewInputContext } from './controller-screen-layout.js';
|
||||
|
||||
/** Every button on the media timing review screen, named by what it does. */
|
||||
export type MediaTimingReviewControllerAction = ControllerReviewAction;
|
||||
|
||||
/** The review shows either the timing editor or the "Stop reviewing?" step. */
|
||||
export type MediaTimingReviewStep = ControllerReviewStep;
|
||||
|
||||
/** Hint text per action for the review buttons, e.g. `A` or `L1 + ▶`; null when unbound. */
|
||||
export function formatMediaTimingReviewControllerHints(
|
||||
config: ResolvedControllerConfig,
|
||||
mapping: string | null | undefined,
|
||||
): Record<MediaTimingReviewControllerAction, string | null> {
|
||||
return formatControllerScreenHints(
|
||||
CONTROLLER_REVIEW_SCREEN,
|
||||
config.reviewBindings,
|
||||
config,
|
||||
mapping,
|
||||
);
|
||||
}
|
||||
|
||||
export function createMediaTimingReviewControllerLayout(options: {
|
||||
getStep: () => MediaTimingReviewStep;
|
||||
dispatch: (action: MediaTimingReviewControllerAction) => void;
|
||||
}): ControllerScreenLayout {
|
||||
return createControllerComboScreenLayout({
|
||||
screen: CONTROLLER_REVIEW_SCREEN,
|
||||
getBindings: (config) => config.reviewBindings,
|
||||
getStep: options.getStep,
|
||||
dispatch: options.dispatch,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user