Skip to content

Commit

Permalink
Improve code quality
Browse files Browse the repository at this point in the history
  • Loading branch information
pmanikas committed Nov 20, 2024
1 parent 38735d2 commit 16b7ff1
Show file tree
Hide file tree
Showing 2 changed files with 197 additions and 149 deletions.
334 changes: 185 additions & 149 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -1,181 +1,217 @@
import { ControllerMapping, buttonSvgClasses, controllerMappings } from "./controller-mappings";
import { SvgService } from "./services/svg.service";

const controllerDisplay = document.getElementById('controller-display')!;
const controllerName = document.getElementById('controller-name')!;

let currentController: ControllerMapping | null = null;

function detectController(gamepad: Gamepad): void {
if (gamepad.id.includes('Xbox')) {
currentController = controllerMappings['Xbox'];
} else if (gamepad.id.includes('PLAYSTATION(R)3')) {
currentController = controllerMappings['PS3'];
} else if (gamepad.id.includes('Wireless Controller')) {
currentController = controllerMappings['PS4'];
} else if (gamepad.id.includes('DualSense Wireless Controller')) {
currentController = controllerMappings['PS5'];
} else if (gamepad.id.includes('Joy-Con (L)')) {
currentController = controllerMappings['JoyConL'];
} else if (gamepad.id.includes('Joy-Con (R)')) {
currentController = controllerMappings['JoyConR'];
} else if (gamepad.id.includes('Joy-Con L+R')) {
currentController = controllerMappings['JoyConLR'];
} else currentController = null;

if (currentController) {
controllerName.textContent = currentController.name;
renderControllerDisplay(currentController);
} else controllerName.textContent = 'Unknown Controller';
interface GamepadState {
currentController: ControllerMapping | null;
lastTimestamp: number;
}

async function renderControllerDisplay(controller: ControllerMapping): Promise<void> {
controllerDisplay.innerHTML = '';
class GamepadController {
private static readonly FPS = 30;
private static readonly DEFAULT_VIBRATION_DURATION = 100;
private static readonly DEFAULT_VIBRATION_MAGNITUDE = 1;

private readonly state: GamepadState = {
currentController: null,
lastTimestamp: 0
};

private readonly elements = {
display: document.getElementById('controller-display') as HTMLElement,
name: document.getElementById('controller-name') as HTMLElement,
svgContainer: document.querySelector('.svg-container') as HTMLElement
};

constructor() {
this.bindEventListeners();
}

private bindEventListeners(): void {
window.addEventListener('gamepadconnected', this.handleGamepadConnect.bind(this));
window.addEventListener('gamepaddisconnected', this.handleGamepadDisconnect.bind(this));
window.addEventListener('beforeunload', this.cleanup.bind(this));
window.addEventListener('unload', this.cleanup.bind(this));
}

private handleGamepadConnect(event: GamepadEvent): void {
const { gamepad } = event;
if (!gamepad) return;

this.detectController(gamepad);
this.initializeVibration(gamepad);
requestAnimationFrame(this.gameLoop.bind(this));
}

private handleGamepadDisconnect(): void {
this.cleanup();
}

private detectController(gamepad: Gamepad): void {
const controllerMap = new Map([
['Xbox', (id: string) => id.includes('Xbox')],
['PS3', (id: string) => id.includes('PLAYSTATION(R)3')],
['PS4', (id: string) => id.includes('Wireless Controller')],
['PS5', (id: string) => id.includes('DualSense Wireless Controller')],
['JoyConL', (id: string) => id.includes('Joy-Con (L)')],
['JoyConR', (id: string) => id.includes('Joy-Con (R)')],
['JoyConLR', (id: string) => id.includes('Joy-Con L+R')]
]);

for (const [key, predicate] of controllerMap) {
if (predicate(gamepad.id)) {
this.state.currentController = controllerMappings[key];
break;
}
}

this.updateControllerDisplay();
}

private async updateControllerDisplay(): Promise<void> {
const { currentController } = this.state;
this.elements.name.textContent = currentController?.name ?? 'Unknown Controller';

if (!currentController) return;

this.elements.display.innerHTML = '';
await this.renderButtons(currentController);
await this.renderAxes(currentController);
await this.renderControllerSvg(currentController);
}

// buttons
private async renderButtons(controller: ControllerMapping): Promise<void> {
controller.buttons.forEach(button => {
const labelElement = document.createElement('label');
labelElement.className = 'controller-button';
labelElement.id = button;
labelElement.textContent = button;
controllerDisplay.appendChild(labelElement);
const labelElement = document.createElement('label');
Object.assign(labelElement, {
className: 'controller-button',
id: button,
textContent: button
});
this.elements.display.appendChild(labelElement);
});

// axes
controller.axes.forEach(axis => {
const axisElement = document.createElement('div');
axisElement.className = 'controller-axis';
axisElement.id = axis;
axisElement.textContent = axis;
controllerDisplay.appendChild(axisElement);
}

private async renderAxes(controller: ControllerMapping): Promise<void> {
controller.axes?.forEach(axis => {
const axisElement = document.createElement('div');
Object.assign(axisElement, {
className: 'controller-axis',
id: axis,
textContent: axis
});
this.elements.display.appendChild(axisElement);
});

// svg
if (controller.svg) {
const svgElement = await SvgService.getByUrl(`./assets/svg/${controller.svg}`);
const svgContainer = document.querySelector('.svg-container');
if (svgContainer && svgElement) {
svgContainer.innerHTML = '';
svgContainer.appendChild(svgElement);
}
}

private async renderControllerSvg(controller: ControllerMapping): Promise<void> {
if (!controller.svg) return;

try {
const svgElement = await SvgService.getByUrl(`./assets/svg/${controller.svg}`);
if (svgElement) {
this.elements.svgContainer.innerHTML = '';
this.elements.svgContainer.appendChild(svgElement);
}
} catch (error) {
console.error('Failed to load controller SVG:', error);
}
}
}

function triggerImageButton(button: string) {
document.querySelector(`.button.${buttonSvgClasses[button]}`)?.classList.add('active');
}

function releaseImageButton(button: string) {
document.querySelector(`.button.${buttonSvgClasses[button]}`)?.classList.remove('active');
}

function updateControllerState(gamepad: Gamepad): void {
private updateButtonState(gamepad: Gamepad): void {
const { currentController } = this.state;
if (!currentController) return;

currentController.buttons.forEach((button, index) => {
const buttonElement = document.getElementById(button);
if (buttonElement) {
// THIS IS WHERE THE MAGIC HAPPENS
if (gamepad.buttons[index].pressed) {
triggerImageButton(button);
buttonElement.classList.add('button-pressed');
} else {
releaseImageButton(button);
buttonElement.classList.remove('button-pressed');
}
}
const buttonElement = document.getElementById(button);
const buttonState = gamepad.buttons[index]?.pressed;

if (buttonElement && typeof buttonState === 'boolean') {
const svgButton = document.querySelector(`.button.${buttonSvgClasses[button]}`);
buttonElement.classList.toggle('button-pressed', buttonState);
svgButton?.classList.toggle('active', buttonState);
}
});
}

currentController.axes.forEach((axis, index) => {
const axisElement = document.getElementById(axis);
if (axisElement) {
axisElement.textContent = `${axis}: ${gamepad.axes[index].toFixed(2)}`;
}
private updateAxisState(gamepad: Gamepad): void {
const { currentController } = this.state;
if (!currentController) return;

// we need them in pairs
if(index % 2 === 0) {
const x = gamepad.axes[index];
const y = gamepad.axes[index + 1];
const stick = document.querySelector(`.${buttonSvgClasses[axis]}`) as HTMLElement;
if(stick) {
stick.style.transform = `translate(${x * 10}px, ${y * 10}px)`;
}
currentController.axes?.forEach((axis, index) => {
const axisElement = document.getElementById(axis);
if (axisElement) {
axisElement.textContent = `${axis}: ${gamepad.axes[index]?.toFixed(2) ?? 0}`;
}

if (index % 2 === 0) {
const stick = document.querySelector(`.${buttonSvgClasses[axis]}`) as HTMLElement;
if (stick) {
const x = gamepad.axes[index] ?? 0;
const y = gamepad.axes[index + 1] ?? 0;
stick.style.transform = `translate(${x * 10}px, ${y * 10}px)`;
}
// set translate x and y pairs for each stick. Array values come in pairs, first the first 2 then the other 2 and so on
});
}

let lastTimestamp = 0;

function gameLoop(t: number): void {
const FPS: number = 30;
}
});
}

if ((t - lastTimestamp) < (1000 / FPS)) {
requestAnimationFrame(gameLoop);
return;
private gameLoop(timestamp: number): void {
if ((timestamp - this.state.lastTimestamp) < (1000 / GamepadController.FPS)) {
requestAnimationFrame(this.gameLoop.bind(this));
return;
}

const gamepads = navigator.getGamepads();
const gamepad = gamepads.find((gamepad) => !!gamepad);

const gamepad = navigator.getGamepads().find(Boolean);
if (gamepad) {
if (!currentController) detectController(gamepad);
updateControllerState(gamepad);
if (!this.state.currentController) {
this.detectController(gamepad);
}
this.updateButtonState(gamepad);
this.updateAxisState(gamepad);
}

lastTimestamp = t;
requestAnimationFrame(gameLoop);
}
this.state.lastTimestamp = timestamp;
requestAnimationFrame(this.gameLoop.bind(this));
}

function initVibration(gamepad: Gamepad): void {
const vibrationActuator = gamepad.vibrationActuator;
if(!vibrationActuator) return;
vibrate(100);
const existedVibrateButton = document.querySelector('.vibrate-button');
if(existedVibrateButton) existedVibrateButton.remove();
const vibrateButton = document.createElement('button');
vibrateButton.className = 'vibrate-button';
vibrateButton.textContent = 'Vibrate';
document.body.appendChild(vibrateButton);
vibrateButton.addEventListener('click', () => vibrate(100));
}
private initializeVibration(gamepad: Gamepad): void {
if (!gamepad.vibrationActuator) return;

function vibrate(duration: number = 100, magnitude = 1): void {
if(!currentController) return;
const gamepads = navigator.getGamepads();
const gamepad = gamepads.find((gamepad) => !!gamepad);

if (gamepad && gamepad.vibrationActuator) {
const vibrationActuator = gamepad.vibrationActuator;
vibrationActuator?.playEffect('dual-rumble', {
duration,
strongMagnitude: magnitude,
weakMagnitude: magnitude
});
}
}
const existingButton = document.querySelector('.vibrate-button');
existingButton?.remove();

function start(event: GamepadEvent): void {
if(!event.gamepad) return;
console.log('Gamepad connected:', event.gamepad);
detectController(event.gamepad);
if(event.gamepad.vibrationActuator) initVibration(event.gamepad);
requestAnimationFrame(gameLoop);
}
const vibrateButton = document.createElement('button');
Object.assign(vibrateButton, {
className: 'vibrate-button',
textContent: 'Vibrate'
});

function stop(): void {
currentController = null;
resetDOM();
}
vibrateButton.addEventListener('click', () =>
this.vibrate(GamepadController.DEFAULT_VIBRATION_DURATION)
);

document.body.appendChild(vibrateButton);
this.vibrate(GamepadController.DEFAULT_VIBRATION_DURATION);
}

private vibrate(duration = GamepadController.DEFAULT_VIBRATION_DURATION,
magnitude = GamepadController.DEFAULT_VIBRATION_MAGNITUDE): void {
const gamepad = navigator.getGamepads().find(Boolean);
const actuator = gamepad?.vibrationActuator;

actuator?.playEffect('dual-rumble', {
duration,
strongMagnitude: magnitude,
weakMagnitude: magnitude
});
}

function resetDOM(): void {
controllerName.textContent = 'No controller connected';
controllerDisplay.innerHTML = 'Please connect your bluetooth controller!';
document.querySelector('.svg-container').innerHTML = '';
private cleanup(): void {
this.state.currentController = null;
this.elements.name.textContent = 'No controller connected';
this.elements.display.innerHTML = 'Please connect your bluetooth controller!';
this.elements.svgContainer.innerHTML = '';
document.querySelector('.vibrate-button')?.remove();
}
}

window.addEventListener('gamepadconnected', start);
window.addEventListener('gamepaddisconnected', stop);
window.addEventListener('beforeunload', stop);
window.addEventListener('unload', stop);
new GamepadController();
12 changes: 12 additions & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "es2015",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"],
"exclude": ["node_modules", "**/*.spec.ts"]
}

0 comments on commit 16b7ff1

Please sign in to comment.