mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
- Drop the Alt Horizontal row from Alt+C; no action read this binding - Remove the key from config defaults, resolver, IPC validator, and types - Regenerate config.example.jsonc and remove the inactive-binding exemption
488 lines
14 KiB
TypeScript
488 lines
14 KiB
TypeScript
import type {
|
|
ControllerAxisBinding,
|
|
ControllerButtonBinding,
|
|
ControllerButtonIndicesConfig,
|
|
ControllerDpadFallback,
|
|
ResolvedControllerAxisBinding,
|
|
ResolvedControllerBindingsConfig,
|
|
ResolvedControllerDiscreteBinding,
|
|
} from '../../types/runtime';
|
|
import {
|
|
isControllerFaceButtonLabels,
|
|
isControllerScreenAction,
|
|
parseControllerDiscreteBinding,
|
|
parseControllerReviewBinding,
|
|
type ControllerScreenBindings,
|
|
type ControllerScreenDefinition,
|
|
} from '../../shared/controller-review';
|
|
import { CONTROLLER_SCREENS } from '../../shared/controller-screens';
|
|
import { ResolveContext } from './context';
|
|
import { asBoolean, asNumber, asString, isObject } from './shared';
|
|
|
|
const FACE_BUTTON_LABELS_EXPECTED = "Expected 'xbox', 'nintendo', or 'playstation'.";
|
|
|
|
const CONTROLLER_BUTTON_BINDINGS = [
|
|
'none',
|
|
'select',
|
|
'buttonSouth',
|
|
'buttonEast',
|
|
'buttonNorth',
|
|
'buttonWest',
|
|
'leftShoulder',
|
|
'rightShoulder',
|
|
'leftStickPress',
|
|
'rightStickPress',
|
|
'leftTrigger',
|
|
'rightTrigger',
|
|
] as const;
|
|
|
|
const CONTROLLER_AXIS_BINDINGS = [
|
|
'leftStickX',
|
|
'leftStickY',
|
|
'rightStickX',
|
|
'rightStickY',
|
|
] as const;
|
|
|
|
const CONTROLLER_AXIS_INDEX_BY_BINDING: Record<ControllerAxisBinding, number> = {
|
|
leftStickX: 0,
|
|
leftStickY: 1,
|
|
rightStickX: 3,
|
|
rightStickY: 4,
|
|
};
|
|
|
|
const CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING: Record<
|
|
Exclude<ControllerButtonBinding, 'none'>,
|
|
keyof Required<ControllerButtonIndicesConfig>
|
|
> = {
|
|
select: 'select',
|
|
buttonSouth: 'buttonSouth',
|
|
buttonEast: 'buttonEast',
|
|
buttonNorth: 'buttonNorth',
|
|
buttonWest: 'buttonWest',
|
|
leftShoulder: 'leftShoulder',
|
|
rightShoulder: 'rightShoulder',
|
|
leftStickPress: 'leftStickPress',
|
|
rightStickPress: 'rightStickPress',
|
|
leftTrigger: 'leftTrigger',
|
|
rightTrigger: 'rightTrigger',
|
|
};
|
|
|
|
const CONTROLLER_AXIS_FALLBACK_BY_SLOT = {
|
|
leftStickHorizontal: 'horizontal',
|
|
leftStickVertical: 'vertical',
|
|
rightStickVertical: 'none',
|
|
} as const satisfies Record<string, ControllerDpadFallback>;
|
|
|
|
const CONTROLLER_BUTTON_INDEX_KEYS = [
|
|
'select',
|
|
'buttonSouth',
|
|
'buttonEast',
|
|
'buttonNorth',
|
|
'buttonWest',
|
|
'leftShoulder',
|
|
'rightShoulder',
|
|
'leftStickPress',
|
|
'rightStickPress',
|
|
'leftTrigger',
|
|
'rightTrigger',
|
|
] as const;
|
|
|
|
const CONTROLLER_DISCRETE_BINDING_KEYS = [
|
|
'toggleLookup',
|
|
'closeLookup',
|
|
'toggleKeyboardOnlyMode',
|
|
'mineCard',
|
|
'quitMpv',
|
|
'previousAudio',
|
|
'nextAudio',
|
|
'playCurrentAudio',
|
|
'toggleMpvPause',
|
|
'holdSecondarySub',
|
|
'previousEntry',
|
|
'nextEntry',
|
|
'toggleCharacterNavigation',
|
|
'previousSubtitle',
|
|
'nextSubtitle',
|
|
'replaySubtitle',
|
|
'playNextSubtitle',
|
|
] as const;
|
|
|
|
const CONTROLLER_AXIS_BINDING_KEYS = [
|
|
'leftStickHorizontal',
|
|
'leftStickVertical',
|
|
'rightStickVertical',
|
|
] as const;
|
|
|
|
const RESERVED_CONTROLLER_PROFILE_IDS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
|
|
type ControllerBindingsTarget = Required<ResolvedControllerBindingsConfig>;
|
|
type ControllerButtonIndicesTarget = Required<ControllerButtonIndicesConfig>;
|
|
|
|
function isControllerDpadFallback(value: unknown): value is ControllerDpadFallback {
|
|
return value === 'none' || value === 'horizontal' || value === 'vertical';
|
|
}
|
|
|
|
function resolveLegacyDiscreteBinding(
|
|
value: ControllerButtonBinding,
|
|
buttonIndices: Required<ControllerButtonIndicesConfig>,
|
|
): ResolvedControllerDiscreteBinding {
|
|
if (value === 'none') {
|
|
return { kind: 'none' };
|
|
}
|
|
return {
|
|
kind: 'button',
|
|
buttonIndex: buttonIndices[CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING[value]],
|
|
};
|
|
}
|
|
|
|
function resolveLegacyAxisBinding(
|
|
value: ControllerAxisBinding,
|
|
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
|
|
): ResolvedControllerAxisBinding {
|
|
return {
|
|
kind: 'axis',
|
|
axisIndex: CONTROLLER_AXIS_INDEX_BY_BINDING[value],
|
|
dpadFallback: CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot],
|
|
};
|
|
}
|
|
|
|
function parseAxisBindingObject(
|
|
value: unknown,
|
|
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
|
|
): ResolvedControllerAxisBinding | null {
|
|
if (isObject(value) && value.kind === 'none') {
|
|
return { kind: 'none' };
|
|
}
|
|
if (!isObject(value) || value.kind !== 'axis') return null;
|
|
if (
|
|
typeof value.axisIndex !== 'number' ||
|
|
!Number.isInteger(value.axisIndex) ||
|
|
value.axisIndex < 0
|
|
) {
|
|
return null;
|
|
}
|
|
if (value.dpadFallback !== undefined && !isControllerDpadFallback(value.dpadFallback)) {
|
|
return null;
|
|
}
|
|
return {
|
|
kind: 'axis',
|
|
axisIndex: value.axisIndex,
|
|
dpadFallback: value.dpadFallback ?? CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot],
|
|
};
|
|
}
|
|
|
|
function applyControllerButtonIndices(
|
|
source: unknown,
|
|
target: ControllerButtonIndicesTarget,
|
|
pathPrefix: string,
|
|
warn: ResolveContext['warn'],
|
|
): void {
|
|
if (!isObject(source)) return;
|
|
|
|
for (const key of CONTROLLER_BUTTON_INDEX_KEYS) {
|
|
const value = asNumber(source[key]);
|
|
if (value !== undefined && value >= 0 && Number.isInteger(value)) {
|
|
target[key] = value;
|
|
} else if (source[key] !== undefined) {
|
|
warn(`${pathPrefix}.${key}`, source[key], target[key], 'Expected non-negative integer.');
|
|
}
|
|
}
|
|
}
|
|
|
|
function applyControllerBindings(
|
|
source: unknown,
|
|
target: ControllerBindingsTarget,
|
|
buttonIndices: ControllerButtonIndicesTarget,
|
|
pathPrefix: string,
|
|
warn: ResolveContext['warn'],
|
|
): void {
|
|
if (!isObject(source)) return;
|
|
|
|
for (const key of CONTROLLER_DISCRETE_BINDING_KEYS) {
|
|
const bindingValue = source[key];
|
|
const legacyValue = asString(bindingValue);
|
|
if (
|
|
legacyValue !== undefined &&
|
|
CONTROLLER_BUTTON_BINDINGS.includes(
|
|
legacyValue as (typeof CONTROLLER_BUTTON_BINDINGS)[number],
|
|
)
|
|
) {
|
|
target[key] = resolveLegacyDiscreteBinding(
|
|
legacyValue as ControllerButtonBinding,
|
|
buttonIndices,
|
|
);
|
|
continue;
|
|
}
|
|
const parsedObject = parseControllerDiscreteBinding(bindingValue);
|
|
if (parsedObject) {
|
|
target[key] = parsedObject;
|
|
} else if (bindingValue !== undefined) {
|
|
warn(
|
|
`${pathPrefix}.${key}`,
|
|
bindingValue,
|
|
target[key],
|
|
"Expected legacy controller button name or binding object with kind 'none', 'button', or 'axis', and an optional button or axis 'hold'.",
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const key of CONTROLLER_AXIS_BINDING_KEYS) {
|
|
const bindingValue = source[key];
|
|
const legacyValue = asString(bindingValue);
|
|
if (
|
|
legacyValue !== undefined &&
|
|
CONTROLLER_AXIS_BINDINGS.includes(legacyValue as (typeof CONTROLLER_AXIS_BINDINGS)[number])
|
|
) {
|
|
target[key] = resolveLegacyAxisBinding(legacyValue as ControllerAxisBinding, key);
|
|
continue;
|
|
}
|
|
if (legacyValue === 'none') {
|
|
target[key] = { kind: 'none' };
|
|
continue;
|
|
}
|
|
const parsedObject = parseAxisBindingObject(bindingValue, key);
|
|
if (parsedObject) {
|
|
target[key] = parsedObject;
|
|
} else if (bindingValue !== undefined) {
|
|
warn(
|
|
`${pathPrefix}.${key}`,
|
|
bindingValue,
|
|
target[key],
|
|
"Expected legacy controller axis name ('none' allowed) or binding object with kind 'axis'.",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function applyControllerScreenBindings(
|
|
screen: ControllerScreenDefinition,
|
|
source: unknown,
|
|
target: ControllerScreenBindings,
|
|
pathPrefix: string,
|
|
warn: ResolveContext['warn'],
|
|
): void {
|
|
if (source === undefined) return;
|
|
if (!isObject(source)) {
|
|
warn(pathPrefix, source, target, `Expected an object keyed by ${screen.describe} action.`);
|
|
return;
|
|
}
|
|
for (const [action, value] of Object.entries(source)) {
|
|
if (!isControllerScreenAction(screen, action)) {
|
|
warn(`${pathPrefix}.${action}`, value, undefined, `Unknown ${screen.describe} action.`);
|
|
continue;
|
|
}
|
|
const parsed = parseControllerReviewBinding(value);
|
|
if (parsed) {
|
|
target[action] = parsed;
|
|
} else {
|
|
warn(
|
|
`${pathPrefix}.${action}`,
|
|
value,
|
|
target[action],
|
|
'Expected {"kind": "none"} or {"press": input, "hold"?: input} with button, axis, face or dpad inputs.',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function applyControllerConfig(context: ResolveContext): void {
|
|
const { src, resolved, warn } = context;
|
|
if (!isObject(src.controller)) return;
|
|
|
|
const enabled = asBoolean(src.controller.enabled);
|
|
if (enabled !== undefined) {
|
|
resolved.controller.enabled = enabled;
|
|
} else if (src.controller.enabled !== undefined) {
|
|
warn(
|
|
'controller.enabled',
|
|
src.controller.enabled,
|
|
resolved.controller.enabled,
|
|
'Expected boolean.',
|
|
);
|
|
}
|
|
|
|
const preferredGamepadId = asString(src.controller.preferredGamepadId);
|
|
if (preferredGamepadId !== undefined) {
|
|
resolved.controller.preferredGamepadId = preferredGamepadId;
|
|
}
|
|
|
|
const preferredGamepadLabel = asString(src.controller.preferredGamepadLabel);
|
|
if (preferredGamepadLabel !== undefined) {
|
|
resolved.controller.preferredGamepadLabel = preferredGamepadLabel;
|
|
}
|
|
|
|
const smoothScroll = asBoolean(src.controller.smoothScroll);
|
|
if (smoothScroll !== undefined) {
|
|
resolved.controller.smoothScroll = smoothScroll;
|
|
} else if (src.controller.smoothScroll !== undefined) {
|
|
warn(
|
|
'controller.smoothScroll',
|
|
src.controller.smoothScroll,
|
|
resolved.controller.smoothScroll,
|
|
'Expected boolean.',
|
|
);
|
|
}
|
|
|
|
const triggerInputMode = asString(src.controller.triggerInputMode);
|
|
if (
|
|
triggerInputMode === 'auto' ||
|
|
triggerInputMode === 'digital' ||
|
|
triggerInputMode === 'analog'
|
|
) {
|
|
resolved.controller.triggerInputMode = triggerInputMode;
|
|
} else if (src.controller.triggerInputMode !== undefined) {
|
|
warn(
|
|
'controller.triggerInputMode',
|
|
src.controller.triggerInputMode,
|
|
resolved.controller.triggerInputMode,
|
|
"Expected 'auto', 'digital', or 'analog'.",
|
|
);
|
|
}
|
|
|
|
const faceButtonLabels = src.controller.faceButtonLabels;
|
|
if (isControllerFaceButtonLabels(faceButtonLabels)) {
|
|
resolved.controller.faceButtonLabels = faceButtonLabels;
|
|
} else if (faceButtonLabels !== undefined) {
|
|
warn(
|
|
'controller.faceButtonLabels',
|
|
faceButtonLabels,
|
|
resolved.controller.faceButtonLabels,
|
|
FACE_BUTTON_LABELS_EXPECTED,
|
|
);
|
|
}
|
|
|
|
const boundedNumberKeys = [
|
|
'scrollPixelsPerSecond',
|
|
'horizontalJumpPixels',
|
|
'repeatDelayMs',
|
|
'repeatIntervalMs',
|
|
] as const;
|
|
for (const key of boundedNumberKeys) {
|
|
const value = asNumber(src.controller[key]);
|
|
if (value !== undefined && Math.floor(value) > 0) {
|
|
resolved.controller[key] = Math.floor(value) as (typeof resolved.controller)[typeof key];
|
|
} else if (src.controller[key] !== undefined) {
|
|
warn(
|
|
`controller.${key}`,
|
|
src.controller[key],
|
|
resolved.controller[key],
|
|
'Expected positive number.',
|
|
);
|
|
}
|
|
}
|
|
|
|
const deadzoneKeys = ['stickDeadzone', 'triggerDeadzone'] as const;
|
|
for (const key of deadzoneKeys) {
|
|
const value = asNumber(src.controller[key]);
|
|
if (value !== undefined && value >= 0 && value <= 1) {
|
|
resolved.controller[key] = value as (typeof resolved.controller)[typeof key];
|
|
} else if (src.controller[key] !== undefined) {
|
|
warn(
|
|
`controller.${key}`,
|
|
src.controller[key],
|
|
resolved.controller[key],
|
|
'Expected number between 0 and 1.',
|
|
);
|
|
}
|
|
}
|
|
|
|
applyControllerButtonIndices(
|
|
src.controller.buttonIndices,
|
|
resolved.controller.buttonIndices,
|
|
'controller.buttonIndices',
|
|
warn,
|
|
);
|
|
applyControllerBindings(
|
|
src.controller.bindings,
|
|
resolved.controller.bindings,
|
|
resolved.controller.buttonIndices,
|
|
'controller.bindings',
|
|
warn,
|
|
);
|
|
for (const screen of CONTROLLER_SCREENS) {
|
|
applyControllerScreenBindings(
|
|
screen,
|
|
src.controller[screen.configKey],
|
|
resolved.controller[screen.configKey],
|
|
`controller.${screen.configKey}`,
|
|
warn,
|
|
);
|
|
}
|
|
|
|
if (isObject(src.controller.profiles)) {
|
|
for (const [profileId, rawProfile] of Object.entries(src.controller.profiles)) {
|
|
if (RESERVED_CONTROLLER_PROFILE_IDS.has(profileId)) {
|
|
warn(
|
|
`controller.profiles.${profileId}`,
|
|
rawProfile,
|
|
undefined,
|
|
'Reserved profile id is not allowed.',
|
|
);
|
|
continue;
|
|
}
|
|
if (!isObject(rawProfile)) {
|
|
warn(
|
|
`controller.profiles.${profileId}`,
|
|
rawProfile,
|
|
undefined,
|
|
'Expected controller profile object.',
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const label = asString(rawProfile.label);
|
|
if (rawProfile.label !== undefined && label === undefined) {
|
|
warn(
|
|
`controller.profiles.${profileId}.label`,
|
|
rawProfile.label,
|
|
profileId,
|
|
'Expected string.',
|
|
);
|
|
}
|
|
|
|
const profile = {
|
|
label: label ?? profileId,
|
|
buttonIndices: structuredClone(resolved.controller.buttonIndices),
|
|
bindings: structuredClone(resolved.controller.bindings),
|
|
faceButtonLabels: resolved.controller.faceButtonLabels,
|
|
reviewBindings: structuredClone(resolved.controller.reviewBindings),
|
|
fieldGroupingBindings: structuredClone(resolved.controller.fieldGroupingBindings),
|
|
};
|
|
// Set per controller from Alt+C; falls back to controller.faceButtonLabels.
|
|
if (isControllerFaceButtonLabels(rawProfile.faceButtonLabels)) {
|
|
profile.faceButtonLabels = rawProfile.faceButtonLabels;
|
|
} else if (rawProfile.faceButtonLabels !== undefined) {
|
|
warn(
|
|
`controller.profiles.${profileId}.faceButtonLabels`,
|
|
rawProfile.faceButtonLabels,
|
|
profile.faceButtonLabels,
|
|
FACE_BUTTON_LABELS_EXPECTED,
|
|
);
|
|
}
|
|
applyControllerButtonIndices(
|
|
rawProfile.buttonIndices,
|
|
profile.buttonIndices,
|
|
`controller.profiles.${profileId}.buttonIndices`,
|
|
warn,
|
|
);
|
|
applyControllerBindings(
|
|
rawProfile.bindings,
|
|
profile.bindings,
|
|
profile.buttonIndices,
|
|
`controller.profiles.${profileId}.bindings`,
|
|
warn,
|
|
);
|
|
for (const screen of CONTROLLER_SCREENS) {
|
|
applyControllerScreenBindings(
|
|
screen,
|
|
rawProfile[screen.configKey],
|
|
profile[screen.configKey],
|
|
`controller.profiles.${profileId}.${screen.configKey}`,
|
|
warn,
|
|
);
|
|
}
|
|
resolved.controller.profiles[profileId] = profile;
|
|
}
|
|
}
|
|
}
|