contro-max
Version:
Game controls done in the best possible way!
344 lines (343 loc) • 17.9 kB
JavaScript
import { mapValues } from 'lodash-es';
import { keysGroup, getMovementKeysGroup } from './keysUtil';
import { keysMovementConfig } from './shared/movement';
import { bindEventListeners } from './eventUtils';
import { GamepadsStore } from './gamepadStore';
import { getButtonLabel } from './gamepad';
import { TypedEventEmitter } from './typedEventEmitter';
const mapKeyboardCodes = {
// firefox
// eslint-disable-next-line @typescript-eslint/naming-convention
OS: 'Meta',
};
// TODO enable debug
export class ControMax extends TypedEventEmitter {
options;
/** type only @deprecated */
_commandsRaw = undefined;
inputSchema;
/** Raw set of all pressed key at the moment */
pressedKeys;
/** Disable input handling. */
enabled = true;
userConfig;
pressedKeyOrButtonChanged;
// invoke: {
// [K in keyof D['commands']]: {
// [X in keyof D['commands'][K]]: () => void
// }
// }
constructor(inputSchema, options = {}) {
super();
this.options = options;
// TODO!
const { target = window, additionalEventHandlers = true, defaultControlOptions, requireTrusted = false, listenGamepadsStrategy = 'all-gamepads', gamepadPollingInterval = 250, emitMovement = 'all', } = this.options;
this.inputSchema = {
...inputSchema,
commands: mapValues(inputSchema.commands, group => mapValues(group, (inputCommand) => {
const resolvedCommand = {
keys: [],
gamepad: [],
};
if (inputCommand === null)
return resolvedCommand;
const [...args] = inputCommand;
Object.assign(resolvedCommand, typeof args[1] === 'object' ? args[1] : args[2] ?? {});
// lodash-marker ensureArray
if (typeof args[0] === 'string')
resolvedCommand.keys = [args[0]];
if (Array.isArray(args[0]) && typeof args[0][0] === 'string')
resolvedCommand.keys = args[0];
if (typeof args[1] === 'string')
resolvedCommand.gamepad = [args[1]];
return resolvedCommand;
})),
// TODO
};
if (this.options.storeProvider) {
const promiseOrConfig = this.options.storeProvider.load();
if (typeof promiseOrConfig.then === 'function') {
void promiseOrConfig.then(config => {
this.userConfig = config;
void this.emit('userConfigResolve');
});
}
else {
this.userConfig = promiseOrConfig;
void this.emit('userConfigResolve');
}
}
const getInitialMovementVector = () => ({ x: 0, y: 0, z: 0 });
const currentMovementVector = {
all: getInitialMovementVector(),
keyboard: getInitialMovementVector(),
gamepads: [],
};
/** Emit update */
const updateMovementVector = (type) => {
// todo
if (emitMovement === 'all')
currentMovementVector.all = [currentMovementVector.keyboard, ...Object.values(currentMovementVector.gamepads)].reduce((prevMovement, currentMovement) => ({
x: prevMovement.x + currentMovement.x,
y: prevMovement.y + currentMovement.y,
z: prevMovement.z + currentMovement.z,
}), getInitialMovementVector());
// todo not clear what happened with auto-infer here
const movementVectorToEmit = emitMovement === 'all'
? currentMovementVector.all
: 'keyboard' in type
? currentMovementVector.keyboard
: currentMovementVector[type.gamepadIndex];
const solelyMovementVector = 'keyboard' in type ? currentMovementVector.keyboard : currentMovementVector.gamepads[type.gamepadIndex];
void this.emit('movementUpdate', {
vector: inputSchema.movementVector === '2d' ? { ...movementVectorToEmit, y: undefined } : movementVectorToEmit,
soleVector: solelyMovementVector,
gamepadIndex: 'keyboard' in type ? undefined : type.gamepadIndex,
});
};
this.pressedKeys = new Set();
// eslint-disable-next-line complexity
this.pressedKeyOrButtonChanged = (codeOrButton, buttonPressed, { preventDefault: doPreventDefault = () => { } } = {}) => {
this.emit('pressedKeyOrButtonChanged', { ...codeOrButton, state: buttonPressed });
// ;(keydownEvent ? pressedKeys.add : pressedKeys.delete)(code)
// ignore subsequent keypresses. also possible via event.repeat == true
if ('code' in codeOrButton) {
if (buttonPressed && this.pressedKeys.has(codeOrButton.code)) {
// prevent default
if (defaultControlOptions?.preventDefault)
doPreventDefault();
return;
}
if (!buttonPressed && !this.pressedKeys.has(codeOrButton.code))
return;
this.pressedKeys[buttonPressed ? 'add' : 'delete'](codeOrButton.code);
}
if (!this.enabled)
return;
// lodash-marker
const resolvedSchema = this.userConfig;
for (const [sectionName, section] of Object.entries(this.inputSchema.commands)) {
for (const [name, command] of Object.entries(section)) {
let { keys, disabled, gamepad, preventDefault } = command;
const userOverride = resolvedSchema?.[sectionName]?.[name];
if (userOverride?.keys)
keys = userOverride.keys;
if (userOverride?.gamepad)
gamepad = userOverride.gamepad;
if ('code' in codeOrButton) {
const hasSimpleBind = keys.includes(codeOrButton.code);
const hasModifierBind = keys.some(key => key.includes(`+${codeOrButton.code}`));
if (!hasSimpleBind && !hasModifierBind)
continue;
// if there is a simple bind then check that it is not overlaped with modifier bind
if (hasSimpleBind) {
const isOverlapped = Object.values(resolvedSchema ?? {}).some(sec => Object.values(sec).some(com => com.keys?.some(k => k.includes(`+${codeOrButton.code}`) &&
this.pressedKeys.has(k.split('+')[0]))));
if (isOverlapped)
continue;
}
// if there is a bind with modifier then check that modifier is pressed
if (hasModifierBind) {
const codesWithModifier = keys.filter(key => /^(Shift|Alt|Control|Meta)\+/.test(key) && key.endsWith(`+${codeOrButton.code}`));
const shouldSkip = !codesWithModifier.some(codeWithModifier => {
const [modifier] = codeWithModifier.split('+');
return this.pressedKeys.has(modifier);
});
if (shouldSkip)
continue;
}
}
else if (!gamepad.includes(codeOrButton.button)) {
continue;
}
if (preventDefault ?? defaultControlOptions?.preventDefault ?? true)
doPreventDefault();
if ((disabled ?? defaultControlOptions?.disabled) && buttonPressed)
continue;
void this.emit(buttonPressed ? 'trigger' : 'release', { command: `${sectionName}.${name}`, schema: command });
}
}
// todo do the same here
for (const [sectionName, section] of Object.entries(this.inputSchema.groupedCommands ?? {}))
for (const [name, groupedCommand] of Object.entries(section)) {
const [keyboard, gamepad, options = {}] = groupedCommand;
const emitData = {
command: `${sectionName}.${name}`,
schema: groupedCommand,
};
if ('code' in codeOrButton) {
const resolvedKeys = typeof keyboard === 'string' ? keysGroup(keyboard) : keyboard;
const keyIndex = resolvedKeys.indexOf(codeOrButton.code);
if (keyIndex === -1)
continue;
Object.assign(emitData, {
type: 'keyboard',
index: keyIndex,
key: resolvedKeys[keyIndex],
});
}
else {
const buttonIndex = gamepad.indexOf(codeOrButton.button);
if (buttonIndex === -1)
continue;
Object.assign(emitData, {
type: 'gamepad',
index: buttonIndex,
gamepadIndex: codeOrButton.gamepadIndex,
key: gamepad[buttonIndex],
});
}
if (options.preventDefault ?? defaultControlOptions?.preventDefault ?? true)
doPreventDefault();
if ((options.disabled ?? defaultControlOptions?.disabled) && buttonPressed)
continue;
void this.emit(buttonPressed ? 'triggerGrouped' : 'releaseGrouped', emitData);
}
if (inputSchema.movementKeymap && 'code' in codeOrButton) {
const movementKeys = getMovementKeysGroup(inputSchema.movementKeymap);
let keyIndex = movementKeys.indexOf(codeOrButton.code);
if (keyIndex !== -1) {
// lodash-marker
if (keyIndex >= keysMovementConfig.length)
keyIndex -= keysMovementConfig.length;
const movementAction = keysMovementConfig[keyIndex];
currentMovementVector.keyboard[movementAction[0]] += movementAction[1] * (buttonPressed ? 1 : -1);
if (defaultControlOptions?.preventDefault ?? true)
doPreventDefault();
updateMovementVector({ keyboard: true });
}
}
};
const keyboardEvent = (e) => {
if (options?.captureEvents && !options.captureEvents(e))
return;
const targetEl = e.composedPath()[0];
if ((requireTrusted && !e.isTrusted) || ['input', 'select', 'textarea'].includes(targetEl.tagName?.toLowerCase()))
return;
const keydownEvent = e.type === 'keydown';
let { code } = e;
const fixedCode = Object.entries(mapKeyboardCodes).find(([wrongCode]) => code === wrongCode);
if (fixedCode)
code = fixedCode[1];
// interesting thing. todo: cover with all possible tests
const codeWithoutSide = /^(.+?)(Left|Right)$/.exec(code)?.[1];
const codes = codeWithoutSide ? [code, codeWithoutSide] : [code];
for (const code of codes)
this.pressedKeyOrButtonChanged({ code: code }, keydownEvent, { preventDefault: () => e.preventDefault() });
};
const mouseEvent = (e) => {
const { button } = e;
const mousedownEvent = e.type === 'mousedown';
this.pressedKeyOrButtonChanged({ code: 'Mouse' + button }, mousedownEvent, { preventDefault: () => e.preventDefault() });
};
// BIND EVENTS
bindEventListeners(target, {
keydown: keyboardEvent,
keyup: keyboardEvent,
mousedown: mouseEvent,
mouseup: mouseEvent,
});
const visibilitychangeListener = () => {
if (document.visibilityState !== 'hidden')
return;
for (const code of this.pressedKeys)
this.pressedKeyOrButtonChanged({ code }, false);
};
// For now, all browsers don't fire "keyup" event if the user released key after alt+tab or tab switch
// This should show the difference between libraries for apps and games
if (additionalEventHandlers)
document.addEventListener('visibilitychange', visibilitychangeListener);
// NOTE: The method above still doesn't work with cmd+tab on macos but there is nothing I can with it
const gamepadsStore = new GamepadsStore();
let pollingInterval;
gamepadsStore.on('firstConnected', () => {
let prevPressedButtons = [];
pollingInterval = setInterval(() => {
if (options?.captureEvents && !options.captureEvents())
return;
const allConnectedGamepads = [...GamepadsStore.getConnectedGamepads()];
// TODO handle disablement in other way
// Polling interval would stop if there is no gamepads
const gamepads = listenGamepadsStrategy === 'only-first-gamepad'
? [allConnectedGamepads[0]]
: listenGamepadsStrategy === 'only-last-gamepad'
? [allConnectedGamepads.at(-1)]
: listenGamepadsStrategy === 'all-gamepads'
? allConnectedGamepads
: [];
const newPressedButtons = Object.fromEntries(gamepads.map(({ index: gamepadIndex, buttons }) => [
gamepadIndex,
GamepadsStore.getConnectedGamepads()
.find(({ index }) => index === gamepadIndex)
.buttons.map(({ pressed }) => pressed),
]));
for (const [gamepadIndex, pressedButtons] of Object.entries(newPressedButtons))
for (const [buttonIndex, buttonState] of pressedButtons.entries()) {
// TODO think about better name
let pressEvent = undefined;
if (buttonState && !prevPressedButtons[gamepadIndex]?.[buttonIndex])
pressEvent = true;
if (!buttonState && prevPressedButtons[gamepadIndex]?.[buttonIndex] === true)
pressEvent = false;
if (pressEvent !== undefined)
this.pressedKeyOrButtonChanged({ button: getButtonLabel(+buttonIndex), gamepadIndex }, pressEvent);
}
for (const gamepad of gamepads) {
const newMovement = GamepadsStore.queryStick('left', GamepadsStore.getConnectedGamepads().find(({ index }) => index === gamepad.index));
if (!currentMovementVector.gamepads[gamepad.index])
currentMovementVector.gamepads[gamepad.index] = getInitialMovementVector();
const gamepadVector = currentMovementVector.gamepads[gamepad.index];
if (gamepadVector.x === newMovement.x && gamepadVector.y === newMovement.z)
continue;
gamepadVector.x = newMovement.x;
gamepadVector.z = newMovement.z;
updateMovementVector({ gamepadIndex: gamepad.index });
}
// todo emit all?
const gamepad = gamepads[0];
if (gamepad) {
for (const side of ['left', 'right']) {
const rightStick = GamepadsStore.queryStick(side, GamepadsStore.getConnectedGamepads().find(({ index }) => index === gamepad.index));
this.emit('stickMovement', { stick: side, vector: rightStick });
}
}
prevPressedButtons = newPressedButtons;
}, gamepadPollingInterval);
});
gamepadsStore.on('lastDisconnected', () => {
clearInterval(pollingInterval);
});
}
// query: <K extends keyof T>(category: K, command: keyof T[K], type?: BasicInputType) => boolean
// setTriggered: <K extends keyof T>(category: K, command: keyof T[K], state: boolean | 'toggle') => void
// cleanup fn?
trigger(category, command) {
const eventData = {
command: `${category}.${command}`,
schema: this.inputSchema.commands[category][command],
};
void this.emit('trigger', eventData);
void this.emit('release', eventData);
}
triggerGroupedCommand(category, command, indexOrKey) {
if (!this.inputSchema.groupedCommands)
throw new Error('No grouped commands defined');
// TODO
const schema = this.inputSchema.groupedCommands[category][command];
const eventData = {
command: `${category}.${command}`,
schema,
type: 'keyboard',
// TODO!
index: null,
key: null,
// ...typeof indexOrKey === 'string' ? {
// index: schema[]
// }
// index: keyIndex,
// key: keyboardKeys[keyIndex]!,
};
void this.emit('triggerGrouped', eventData);
void this.emit('releaseGrouped', eventData);
}
}