Controller customization feature

This commit is contained in:
redphx
2024-12-22 17:17:03 +07:00
parent 8ef5a95c88
commit 7b60ba3a3e
89 changed files with 3286 additions and 1188 deletions

View File

@@ -0,0 +1,371 @@
import type { ControllerCustomizationPresetData, ControllerCustomizationPresetRecord } from "@/types/presets";
import { BaseProfileManagerDialog } from "./base-profile-manager-dialog";
import { ControllerCustomizationsTable } from "@/utils/local-db/controller-customizations-table";
import { t } from "@/utils/translation";
import { GamepadKey, GamepadKeyName } from "@/enums/gamepad";
import { ButtonStyle, CE, createButton, createSettingRow } from "@/utils/html";
import { BxSelectElement } from "@/web-components/bx-select";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "@/utils/settings-storages/global-settings-storage";
import { BxEvent } from "@/utils/bx-event";
import { deepClone } from "@/utils/global";
import { StreamSettings } from "@/utils/stream-settings";
import { BxDualNumberStepper } from "@/web-components/bx-dual-number-stepper";
import { NavigationDirection, type NavigationElement } from "../navigation-dialog";
import { setNearby } from "@/utils/navigation-utils";
import type { DualNumberStepperParams } from "@/types/setting-definition";
import { BxNumberStepper } from "@/web-components/bx-number-stepper";
export class ControllerCustomizationsManagerDialog extends BaseProfileManagerDialog<ControllerCustomizationPresetRecord> {
private static instance: ControllerCustomizationsManagerDialog;
public static getInstance = () => ControllerCustomizationsManagerDialog.instance ?? (ControllerCustomizationsManagerDialog.instance = new ControllerCustomizationsManagerDialog(t('controller-customization')));
declare protected $content: HTMLElement;
private $vibrationIntensity!: BxNumberStepper;
private $leftTriggerRange!: BxDualNumberStepper;
private $rightTriggerRange!: BxDualNumberStepper;
private $leftStickDeadzone!: BxDualNumberStepper;
private $rightStickDeadzone!: BxDualNumberStepper;
private $btnDetect!: HTMLButtonElement;
protected BLANK_PRESET_DATA = {
mapping: {},
settings: {
leftTriggerRange: [0, 100],
rightTriggerRange: [0, 100],
leftStickDeadzone: [0, 100],
rightStickDeadzone: [0, 100],
vibrationIntensity: 100,
},
} satisfies ControllerCustomizationPresetData;
private selectsMap: Partial<Record<GamepadKey, HTMLSelectElement>> = {};
private selectsOrder: GamepadKey[] = [];
private isDetectingButton: boolean = false;
private detectIntervalId: number | null = null;
private readonly BUTTONS_ORDER = [
GamepadKey.A, GamepadKey.B,
GamepadKey.X, GamepadKey.Y,
GamepadKey.UP, GamepadKey.RIGHT,
GamepadKey.DOWN, GamepadKey.LEFT,
GamepadKey.LB, GamepadKey.RB,
GamepadKey.LT, GamepadKey.RT,
GamepadKey.SELECT, GamepadKey.START,
GamepadKey.L3, GamepadKey.R3,
GamepadKey.LS, GamepadKey.RS,
GamepadKey.SHARE,
];
constructor(title: string) {
super(title, ControllerCustomizationsTable.getInstance());
this.render();
}
private render() {
const isControllerFriendly = getPref(PrefKey.UI_CONTROLLER_FRIENDLY);
const $rows = CE('div', { class: 'bx-buttons-grid' });
const $baseSelect = CE('select', { class: 'bx-full-width' },
CE('option', { value: '' }, '---'),
CE('option', { value: 'false', _dataset: { label: '🚫' } }, isControllerFriendly ? '🚫' : t('off')),
);
const $baseButtonSelect = $baseSelect.cloneNode(true);
const $baseStickSelect = $baseSelect.cloneNode(true);
const onButtonChanged = (e: Event) => {
// Update preset
if (!(e as any).ignoreOnChange) {
this.updatePreset();
}
};
const boundUpdatePreset = this.updatePreset.bind(this);
for (const gamepadKey of this.BUTTONS_ORDER) {
if (gamepadKey === GamepadKey.SHARE) {
continue;
}
const name = GamepadKeyName[gamepadKey][isControllerFriendly ? 1 : 0];
const $target = (gamepadKey === GamepadKey.LS || gamepadKey === GamepadKey.RS) ? $baseStickSelect : $baseButtonSelect;
$target.appendChild(CE('option', {
value: gamepadKey,
_dataset: { label: GamepadKeyName[gamepadKey][1] },
}, name));
}
for (const gamepadKey of this.BUTTONS_ORDER) {
const [buttonName, buttonPrompt] = GamepadKeyName[gamepadKey];
const $sourceSelect = (gamepadKey === GamepadKey.LS || gamepadKey === GamepadKey.RS) ? $baseStickSelect : $baseButtonSelect;
// Remove current button from selection
const $clonedSelect = $sourceSelect.cloneNode(true) as HTMLSelectElement;
$clonedSelect.querySelector(`option[value="${gamepadKey}"]`)?.remove();
const $select = BxSelectElement.create($clonedSelect);
$select.dataset.index = gamepadKey.toString();
$select.addEventListener('input', onButtonChanged);
this.selectsMap[gamepadKey] = $select;
this.selectsOrder.push(gamepadKey);
const $row = CE('div', {
class: 'bx-controller-key-row',
_nearby: { orientation: 'horizontal' },
},
CE('label', { title: buttonName }, buttonPrompt),
$select,
);
$rows.append($row);
}
// Map nearby elenemts for controller-friendly UI
if (getPref(PrefKey.UI_CONTROLLER_FRIENDLY)) {
for (let i = 0; i < this.selectsOrder.length; i++) {
const $select = this.selectsMap[this.selectsOrder[i] as unknown as GamepadKey] as NavigationElement;
const directions = {
[NavigationDirection.UP]: i - 2,
[NavigationDirection.DOWN]: i + 2,
[NavigationDirection.LEFT]: i - 1,
[NavigationDirection.RIGHT]: i + 1,
};
for (const dir in directions) {
const idx = directions[dir as unknown as NavigationDirection];
if (typeof this.selectsOrder[idx] === 'undefined') {
continue;
}
const $targetSelect = this.selectsMap[this.selectsOrder[idx] as unknown as GamepadKey];
setNearby($select, {
[dir]: $targetSelect,
});
}
}
}
const params: DualNumberStepperParams = {
min: 0,
minDiff: 1,
max: 100,
steps: 1,
};
this.$content = CE('div', { class: 'bx-controller-customizations-container' },
// Detect button
this.$btnDetect = createButton({
label: t('detect-controller-button'),
classes: ['bx-btn-detect'],
style: ButtonStyle.NORMAL_CASE | ButtonStyle.FOCUSABLE | ButtonStyle.FULL_WIDTH,
onClick: () => {
this.startDetectingButton();
},
}),
// Mapping
$rows,
// Vibration intensity
createSettingRow(t('vibration-intensity'),
this.$vibrationIntensity = BxNumberStepper.create('controller_vibration_intensity', 50, 0, 100, {
steps: 10,
suffix: '%',
exactTicks: 20,
customTextValue: (value: any) => {
value = parseInt(value);
return value === 0 ? t('off') : value + '%';
},
}, boundUpdatePreset),
),
// Range settings
createSettingRow(t('left-trigger-range'),
this.$leftTriggerRange = BxDualNumberStepper.create('left-trigger-range', this.BLANK_PRESET_DATA.settings.leftTriggerRange, params, boundUpdatePreset),
),
createSettingRow(t('right-trigger-range'),
this.$rightTriggerRange = BxDualNumberStepper.create('right-trigger-range', this.BLANK_PRESET_DATA.settings.rightTriggerRange, params, boundUpdatePreset),
),
createSettingRow(t('left-stick-deadzone'),
this.$leftStickDeadzone = BxDualNumberStepper.create('left-stick-deadzone', this.BLANK_PRESET_DATA.settings.leftStickDeadzone, params, boundUpdatePreset),
),
createSettingRow(t('right-stick-deadzone'),
this.$rightStickDeadzone = BxDualNumberStepper.create('right-stick-deadzone', this.BLANK_PRESET_DATA.settings.rightStickDeadzone, params, boundUpdatePreset),
),
);
}
private startDetectingButton() {
this.isDetectingButton = true;
const { $btnDetect } = this;
$btnDetect.classList.add('bx-monospaced', 'bx-blink-me');
$btnDetect.disabled = true;
let count = 4;
$btnDetect.textContent = `[${count}] ${t('press-any-button')}`;
this.detectIntervalId = window.setInterval(() => {
count -= 1;
if (count === 0) {
this.stopDetectingButton();
// Re-focus the Detect button
$btnDetect.focus();
return;
}
$btnDetect.textContent = `[${count}] ${t('press-any-button')}`;
}, 1000);
}
private stopDetectingButton() {
const { $btnDetect } = this;
$btnDetect.classList.remove('bx-monospaced', 'bx-blink-me');
$btnDetect.textContent = t('detect-controller-button');
$btnDetect.disabled = false;
this.isDetectingButton = false;
this.detectIntervalId && window.clearInterval(this.detectIntervalId);
this.detectIntervalId = null;
}
async onBeforeMount() {
this.stopDetectingButton();
super.onBeforeMount(...arguments);
}
onBeforeUnmount(): void {
this.stopDetectingButton();
StreamSettings.refreshControllerSettings();
super.onBeforeUnmount();
}
handleGamepad(button: GamepadKey): boolean {
if (!this.isDetectingButton) {
return super.handleGamepad(button);
}
if (button in this.BUTTONS_ORDER) {
this.stopDetectingButton();
const $select = this.selectsMap[button]!;
const $label = $select.previousElementSibling!;
$label.addEventListener('animationend', () => {
$label.classList.remove('bx-horizontal-shaking');
}, { once: true });
$label.classList.add('bx-horizontal-shaking');
// Focus select
if (getPref(PrefKey.UI_CONTROLLER_FRIENDLY)) {
this.dialogManager.focus($select);
}
}
return true;
}
protected switchPreset(id: number): void {
const preset = this.allPresets.data[id];
if (!preset) {
this.currentPresetId = 0;
return;
}
const {
$btnDetect,
$vibrationIntensity,
$leftStickDeadzone,
$rightStickDeadzone,
$leftTriggerRange,
$rightTriggerRange,
selectsMap,
} = this;
const presetData = preset.data;
this.currentPresetId = id;
const isDefaultPreset = id <= 0;
this.updateButtonStates();
// Show/hide Detect button
$btnDetect.classList.toggle('bx-gone', isDefaultPreset);
// Set mappings
let buttonIndex: unknown;
for (buttonIndex in selectsMap) {
buttonIndex = buttonIndex as GamepadKey;
const $select = selectsMap[buttonIndex as GamepadKey];
if (!$select) {
continue;
}
const mappedButton = presetData.mapping[buttonIndex as GamepadKey];
$select.value = typeof mappedButton === 'undefined' ? '' : mappedButton.toString();
$select.disabled = isDefaultPreset;
BxEvent.dispatch($select, 'input', {
ignoreOnChange: true,
manualTrigger: true,
});
}
// Add missing settings
presetData.settings = Object.assign(this.BLANK_PRESET_DATA.settings, presetData.settings);
// Vibration intensity
$vibrationIntensity.value = presetData.settings.vibrationIntensity.toString();
$vibrationIntensity.dataset.disabled = isDefaultPreset.toString();
// Set extra settings
$leftStickDeadzone.dataset.disabled = $rightStickDeadzone.dataset.disabled = $leftTriggerRange.dataset.disabled = $rightTriggerRange.dataset.disabled = isDefaultPreset.toString();
$leftStickDeadzone.setValue(presetData.settings.leftStickDeadzone);
$rightStickDeadzone.setValue(presetData.settings.rightStickDeadzone);
$leftTriggerRange.setValue(presetData.settings.leftTriggerRange);
$rightTriggerRange.setValue(presetData.settings.rightTriggerRange);
}
private updatePreset() {
const newData: ControllerCustomizationPresetData = deepClone(this.BLANK_PRESET_DATA);
// Set mappings
let gamepadKey: unknown;
for (gamepadKey in this.selectsMap) {
const $select = this.selectsMap[gamepadKey as GamepadKey]!;
const value = $select.value;
if (!value) {
continue;
}
const mapTo = (value === 'false') ? false : parseInt(value);
newData.mapping[gamepadKey as GamepadKey] = mapTo;
}
// Set extra settings
Object.assign(newData.settings, {
vibrationIntensity: parseInt(this.$vibrationIntensity.value),
leftStickDeadzone: this.$leftStickDeadzone.getValue(),
rightStickDeadzone: this.$rightStickDeadzone.getValue(),
leftTriggerRange: this.$leftTriggerRange.getValue(),
rightTriggerRange: this.$rightTriggerRange.getValue(),
} satisfies typeof newData.settings);
// Update preset
const preset = this.allPresets.data[this.currentPresetId!];
preset.data = newData;
this.presetsDb.updatePreset(preset);
}
}