mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 12:55:16 -07:00
fix(controller): save remaps per profile, gate modals on enabled (#69)
This commit is contained in:
@@ -1453,6 +1453,104 @@ test('parses descriptor-based controller bindings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('parses controller profiles as per-gamepad binding overrides', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"controller": {
|
||||
"buttonIndices": {
|
||||
"buttonSouth": 0,
|
||||
"leftTrigger": 6
|
||||
},
|
||||
"bindings": {
|
||||
"toggleLookup": { "kind": "button", "buttonIndex": 0 },
|
||||
"quitMpv": "leftTrigger"
|
||||
},
|
||||
"profiles": {
|
||||
"8BitDo SN30": {
|
||||
"label": "8BitDo SN30",
|
||||
"bindings": {
|
||||
"toggleLookup": { "kind": "button", "buttonIndex": 11 },
|
||||
"leftStickVertical": { "kind": "axis", "axisIndex": 7, "dpadFallback": "none" }
|
||||
}
|
||||
},
|
||||
"Xbox Wireless Controller": {
|
||||
"buttonIndices": {
|
||||
"leftTrigger": 8
|
||||
},
|
||||
"bindings": {
|
||||
"quitMpv": "leftTrigger"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
|
||||
assert.deepEqual(config.controller.profiles['8BitDo SN30']?.bindings.toggleLookup, {
|
||||
kind: 'button',
|
||||
buttonIndex: 11,
|
||||
});
|
||||
assert.deepEqual(config.controller.profiles['8BitDo SN30']?.bindings.closeLookup, {
|
||||
kind: 'button',
|
||||
buttonIndex: 1,
|
||||
});
|
||||
assert.deepEqual(config.controller.profiles['8BitDo SN30']?.bindings.leftStickVertical, {
|
||||
kind: 'axis',
|
||||
axisIndex: 7,
|
||||
dpadFallback: 'none',
|
||||
});
|
||||
assert.deepEqual(config.controller.profiles['Xbox Wireless Controller']?.bindings.quitMpv, {
|
||||
kind: 'button',
|
||||
buttonIndex: 8,
|
||||
});
|
||||
assert.equal(
|
||||
config.controller.profiles['Xbox Wireless Controller']?.buttonIndices.leftTrigger,
|
||||
8,
|
||||
);
|
||||
assert.deepEqual(config.controller.bindings.quitMpv, { kind: 'button', buttonIndex: 6 });
|
||||
});
|
||||
|
||||
test('rejects reserved controller profile ids from config', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"controller": {
|
||||
"profiles": {
|
||||
"__proto__": { "label": "polluted" },
|
||||
"constructor": { "label": "ctor" },
|
||||
"prototype": { "label": "proto" },
|
||||
"pad-1": { "label": "Pad 1" }
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
assert.equal(Object.hasOwn(config.controller.profiles, '__proto__'), false);
|
||||
assert.equal(Object.hasOwn(config.controller.profiles, 'constructor'), false);
|
||||
assert.equal(Object.hasOwn(config.controller.profiles, 'prototype'), false);
|
||||
assert.equal(config.controller.profiles['pad-1']?.label, 'Pad 1');
|
||||
assert.equal(
|
||||
warnings.some((warning) => warning.path === 'controller.profiles.constructor'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
warnings.some((warning) => warning.path === 'controller.profiles.prototype'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('controller descriptor config rejects malformed binding objects', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -74,6 +74,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||
},
|
||||
profiles: {},
|
||||
},
|
||||
shortcuts: {
|
||||
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
|
||||
|
||||
@@ -239,6 +239,13 @@ export function buildCoreConfigOptionRegistry(
|
||||
description:
|
||||
'Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.',
|
||||
},
|
||||
{
|
||||
path: 'controller.profiles',
|
||||
kind: 'object',
|
||||
defaultValue: defaultConfig.controller.profiles,
|
||||
description:
|
||||
'Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.',
|
||||
},
|
||||
...discreteBindings.flatMap((binding) => [
|
||||
{
|
||||
path: `controller.bindings.${binding.id}`,
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
import type {
|
||||
ControllerAxisBinding,
|
||||
ControllerAxisDirection,
|
||||
ControllerButtonBinding,
|
||||
ControllerButtonIndicesConfig,
|
||||
ControllerDpadFallback,
|
||||
ResolvedControllerAxisBinding,
|
||||
ResolvedControllerBindingsConfig,
|
||||
ResolvedControllerDiscreteBinding,
|
||||
} from '../../types/runtime';
|
||||
import { ResolveContext } from './context';
|
||||
import { asBoolean, asNumber, asString, isObject } from './shared';
|
||||
|
||||
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',
|
||||
rightStickHorizontal: 'none',
|
||||
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',
|
||||
] as const;
|
||||
|
||||
const CONTROLLER_AXIS_BINDING_KEYS = [
|
||||
'leftStickHorizontal',
|
||||
'leftStickVertical',
|
||||
'rightStickHorizontal',
|
||||
'rightStickVertical',
|
||||
] as const;
|
||||
|
||||
const RESERVED_CONTROLLER_PROFILE_IDS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||
|
||||
type ControllerBindingsTarget = Required<ResolvedControllerBindingsConfig>;
|
||||
type ControllerButtonIndicesTarget = Required<ControllerButtonIndicesConfig>;
|
||||
|
||||
function isControllerAxisDirection(value: unknown): value is ControllerAxisDirection {
|
||||
return value === 'negative' || value === 'positive';
|
||||
}
|
||||
|
||||
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 parseDiscreteBindingObject(value: unknown): ResolvedControllerDiscreteBinding | null {
|
||||
if (!isObject(value) || typeof value.kind !== 'string') return null;
|
||||
if (value.kind === 'none') {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
if (value.kind === 'button') {
|
||||
return typeof value.buttonIndex === 'number' &&
|
||||
Number.isInteger(value.buttonIndex) &&
|
||||
value.buttonIndex >= 0
|
||||
? { kind: 'button', buttonIndex: value.buttonIndex }
|
||||
: null;
|
||||
}
|
||||
if (value.kind === 'axis') {
|
||||
return typeof value.axisIndex === 'number' &&
|
||||
Number.isInteger(value.axisIndex) &&
|
||||
value.axisIndex >= 0 &&
|
||||
isControllerAxisDirection(value.direction)
|
||||
? { kind: 'axis', axisIndex: value.axisIndex, direction: value.direction }
|
||||
: null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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 = parseDiscreteBindingObject(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'.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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'.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 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,
|
||||
);
|
||||
|
||||
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),
|
||||
};
|
||||
applyControllerButtonIndices(
|
||||
rawProfile.buttonIndices,
|
||||
profile.buttonIndices,
|
||||
`controller.profiles.${profileId}.buttonIndices`,
|
||||
warn,
|
||||
);
|
||||
applyControllerBindings(
|
||||
rawProfile.bindings,
|
||||
profile.bindings,
|
||||
profile.buttonIndices,
|
||||
`controller.profiles.${profileId}.bindings`,
|
||||
warn,
|
||||
);
|
||||
resolved.controller.profiles[profileId] = profile;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,150 +1,7 @@
|
||||
import type {
|
||||
ControllerAxisBinding,
|
||||
ControllerAxisBindingConfig,
|
||||
ControllerAxisDirection,
|
||||
ControllerButtonBinding,
|
||||
ControllerButtonIndicesConfig,
|
||||
ControllerDpadFallback,
|
||||
ControllerDiscreteBindingConfig,
|
||||
ResolvedControllerAxisBinding,
|
||||
ResolvedControllerDiscreteBinding,
|
||||
} from '../../types/runtime';
|
||||
import { ResolveContext } from './context';
|
||||
import { applyControllerConfig } from './controller';
|
||||
import { asBoolean, asNumber, asString, isObject } from './shared';
|
||||
|
||||
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',
|
||||
rightStickHorizontal: 'none',
|
||||
rightStickVertical: 'none',
|
||||
} as const satisfies Record<string, ControllerDpadFallback>;
|
||||
|
||||
function isControllerAxisDirection(value: unknown): value is ControllerAxisDirection {
|
||||
return value === 'negative' || value === 'positive';
|
||||
}
|
||||
|
||||
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 parseDiscreteBindingObject(value: unknown): ResolvedControllerDiscreteBinding | null {
|
||||
if (!isObject(value) || typeof value.kind !== 'string') return null;
|
||||
if (value.kind === 'none') {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
if (value.kind === 'button') {
|
||||
return typeof value.buttonIndex === 'number' &&
|
||||
Number.isInteger(value.buttonIndex) &&
|
||||
value.buttonIndex >= 0
|
||||
? { kind: 'button', buttonIndex: value.buttonIndex }
|
||||
: null;
|
||||
}
|
||||
if (value.kind === 'axis') {
|
||||
return typeof value.axisIndex === 'number' &&
|
||||
Number.isInteger(value.axisIndex) &&
|
||||
value.axisIndex >= 0 &&
|
||||
isControllerAxisDirection(value.direction)
|
||||
? { kind: 'axis', axisIndex: value.axisIndex, direction: value.direction }
|
||||
: null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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],
|
||||
};
|
||||
}
|
||||
|
||||
export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
const { src, resolved, warn } = context;
|
||||
|
||||
@@ -245,203 +102,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.controller)) {
|
||||
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 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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.controller.buttonIndices)) {
|
||||
const buttonIndexKeys = [
|
||||
'select',
|
||||
'buttonSouth',
|
||||
'buttonEast',
|
||||
'buttonNorth',
|
||||
'buttonWest',
|
||||
'leftShoulder',
|
||||
'rightShoulder',
|
||||
'leftStickPress',
|
||||
'rightStickPress',
|
||||
'leftTrigger',
|
||||
'rightTrigger',
|
||||
] as const;
|
||||
|
||||
for (const key of buttonIndexKeys) {
|
||||
const value = asNumber(src.controller.buttonIndices[key]);
|
||||
if (value !== undefined && value >= 0 && Number.isInteger(value)) {
|
||||
resolved.controller.buttonIndices[key] = value;
|
||||
} else if (src.controller.buttonIndices[key] !== undefined) {
|
||||
warn(
|
||||
`controller.buttonIndices.${key}`,
|
||||
src.controller.buttonIndices[key],
|
||||
resolved.controller.buttonIndices[key],
|
||||
'Expected non-negative integer.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.controller.bindings)) {
|
||||
const buttonBindingKeys = [
|
||||
'toggleLookup',
|
||||
'closeLookup',
|
||||
'toggleKeyboardOnlyMode',
|
||||
'mineCard',
|
||||
'quitMpv',
|
||||
'previousAudio',
|
||||
'nextAudio',
|
||||
'playCurrentAudio',
|
||||
'toggleMpvPause',
|
||||
] as const;
|
||||
|
||||
for (const key of buttonBindingKeys) {
|
||||
const bindingValue = src.controller.bindings[key];
|
||||
const legacyValue = asString(bindingValue);
|
||||
if (
|
||||
legacyValue !== undefined &&
|
||||
CONTROLLER_BUTTON_BINDINGS.includes(
|
||||
legacyValue as (typeof CONTROLLER_BUTTON_BINDINGS)[number],
|
||||
)
|
||||
) {
|
||||
resolved.controller.bindings[key] = resolveLegacyDiscreteBinding(
|
||||
legacyValue as ControllerButtonBinding,
|
||||
resolved.controller.buttonIndices,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const parsedObject = parseDiscreteBindingObject(bindingValue);
|
||||
if (parsedObject) {
|
||||
resolved.controller.bindings[key] = parsedObject;
|
||||
} else if (bindingValue !== undefined) {
|
||||
warn(
|
||||
`controller.bindings.${key}`,
|
||||
bindingValue,
|
||||
resolved.controller.bindings[key],
|
||||
"Expected legacy controller button name or binding object with kind 'none', 'button', or 'axis'.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const axisBindingKeys = [
|
||||
'leftStickHorizontal',
|
||||
'leftStickVertical',
|
||||
'rightStickHorizontal',
|
||||
'rightStickVertical',
|
||||
] as const;
|
||||
|
||||
for (const key of axisBindingKeys) {
|
||||
const bindingValue = src.controller.bindings[key];
|
||||
const legacyValue = asString(bindingValue);
|
||||
if (
|
||||
legacyValue !== undefined &&
|
||||
CONTROLLER_AXIS_BINDINGS.includes(
|
||||
legacyValue as (typeof CONTROLLER_AXIS_BINDINGS)[number],
|
||||
)
|
||||
) {
|
||||
resolved.controller.bindings[key] = resolveLegacyAxisBinding(
|
||||
legacyValue as ControllerAxisBinding,
|
||||
key,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (legacyValue === 'none') {
|
||||
resolved.controller.bindings[key] = { kind: 'none' };
|
||||
continue;
|
||||
}
|
||||
const parsedObject = parseAxisBindingObject(bindingValue, key);
|
||||
if (parsedObject) {
|
||||
resolved.controller.bindings[key] = parsedObject;
|
||||
} else if (bindingValue !== undefined) {
|
||||
warn(
|
||||
`controller.bindings.${key}`,
|
||||
bindingValue,
|
||||
resolved.controller.bindings[key],
|
||||
"Expected legacy controller axis name ('none' allowed) or binding object with kind 'axis'.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
applyControllerConfig(context);
|
||||
|
||||
if (Array.isArray(src.keybindings)) {
|
||||
resolved.keybindings = src.keybindings.filter(
|
||||
|
||||
Reference in New Issue
Block a user