diff --git a/_sass/styles.scss b/_sass/styles.scss index edacbc39..4910f2f2 100644 --- a/_sass/styles.scss +++ b/_sass/styles.scss @@ -239,6 +239,143 @@ table tr:nth-child(2n) { color: var(--text-col); } +/* Controller visualization */ +.gamepad-visual { + align-items: center; + display: flex; + justify-content: center; + min-height: 16rem; + position: relative; +} + +.gamepad-visual > .text-muted { + grid-column: 1 / -1; +} + +.gamepad-visual-svg { + display: block; + height: auto; + max-height: 28rem; + max-width: 64rem; + overflow: visible; + width: 100%; +} + +.gamepad-visual-control { + fill: var(--bs-primary); + fill-opacity: 0; + pointer-events: none; + transition: fill-opacity 50ms linear; +} + +.gamepad-visual-control.active { + filter: drop-shadow(0 0 1.25rem var(--bs-primary)); +} + +.gamepad-trigger-visual { + --gamepad-trigger-value: 0%; + align-items: center; + background-color: var(--footer-col); + border: 2px solid var(--navbar-border-col); + border-radius: 0.75rem; + display: flex; + flex-direction: column; + isolation: isolate; + min-width: 0; + overflow: hidden; + padding: 0.5rem; + position: absolute; + top: 2.5rem; + transition: border-color 50ms linear, box-shadow 50ms linear; + width: 6rem; + z-index: 1; +} + +.gamepad-trigger-visual:first-child { + left: max(calc(50% - 28rem), 0.5rem); +} + +.gamepad-trigger-visual:last-child { + right: max(calc(50% - 28rem), 0.5rem); +} + +.gamepad-trigger-visual.active { + border-color: var(--bs-primary); + box-shadow: 0 0 1rem rgba(var(--bs-primary-rgb), 0.55); +} + +.gamepad-trigger-fill { + background-color: rgba(var(--bs-primary-rgb), 0.45); + bottom: 0; + height: var(--gamepad-trigger-value); + left: 0; + position: absolute; + transition: height 50ms linear; + width: 100%; + z-index: -1; +} + +.gamepad-trigger-image { + display: block; + height: auto; + max-height: 3rem; + max-width: 100%; + width: 4rem; +} + +.gamepad-trigger-name { + display: none; + font-weight: 700; + line-height: 3rem; +} + +.gamepad-trigger-name.visible { + display: block; +} + +.gamepad-trigger-value { + font-size: 0.75rem; + font-variant-numeric: tabular-nums; + line-height: 1; + margin-top: 0.35rem; +} + +.gamepad-stick-indicator { + fill: var(--bs-primary); + pointer-events: none; + stroke: var(--page-col); + stroke-width: 10; + transition: transform 50ms linear; +} + +@media (max-width: 575.98px) { + .gamepad-visual { + display: grid; + gap: 0.35rem; + grid-template-columns: 3rem minmax(0, 1fr) 3rem; + min-height: 10rem; + } + + .gamepad-trigger-visual { + align-self: start; + left: auto; + margin-top: 1rem; + padding: 0.25rem; + position: relative; + right: auto; + top: auto; + width: auto; + } + + .gamepad-trigger-image { + max-height: 2rem; + } + + .gamepad-trigger-value { + font-size: 0.65rem; + } +} + /* Stick visualization */ .stick-circle { width: 120px; diff --git a/assets/js/gamepad-tester.js b/assets/js/gamepad-tester.js index 06f2287f..044b0232 100644 --- a/assets/js/gamepad-tester.js +++ b/assets/js/gamepad-tester.js @@ -1,3 +1,12 @@ +function createDPadButtonVisuals(centerX, centerY) { + return [ + { index: 12, tag: 'rect', attributes: { x: centerX - 50, y: centerY - 150, width: 100, height: 115, rx: 18 } }, + { index: 13, tag: 'rect', attributes: { x: centerX - 50, y: centerY + 40, width: 100, height: 115, rx: 18 } }, + { index: 14, tag: 'rect', attributes: { x: centerX - 155, y: centerY - 50, width: 115, height: 100, rx: 18 } }, + { index: 15, tag: 'rect', attributes: { x: centerX + 40, y: centerY - 50, width: 115, height: 100, rx: 18 } } + ]; +} + document.addEventListener('DOMContentLoaded', function() { const gamepadHelper = new GamepadHelper() const gamepadHelperVersion = globalThis.gamepadHelperVersion; @@ -8,6 +17,91 @@ document.addEventListener('DOMContentLoaded', function() { let gamepadSelectorContainer = document.getElementById('gamepad-selector-container'); let gamepadInfoSection = document.getElementById('gamepad-info'); let gamepadStatus = document.getElementById('gamepad-status'); + let gamepadVisualButtons = new Map(); + let gamepadVisualSticks = []; + let gamepadVisualTriggers = new Map(); + + const svgNamespace = 'http://www.w3.org/2000/svg'; + const gamepadAssetBasePath = `https://cdn.jsdelivr.net/npm/@lizardbyte/gamepad-helper@${gamepadHelperVersion}/assets/img/gamepads/`; + const gamepadVisualConfigs = { + xbox: { + viewBox: '1050 450 2050 1350', + imagePaths: { + Black: 'xbox/Controller Images/Solid/Solid Black 4k.svg', + White: 'xbox/Controller Images/Solid/Solid White SVG.svg' + }, + buttons: [ + { index: 0, tag: 'circle', attributes: { cx: 2491, cy: 925, r: 70 } }, + { index: 1, tag: 'circle', attributes: { cx: 2606, cy: 811, r: 70 } }, + { index: 2, tag: 'circle', attributes: { cx: 2376, cy: 810, r: 70 } }, + { index: 3, tag: 'circle', attributes: { cx: 2491, cy: 695, r: 70 } }, + { index: 4, tag: 'path', attributes: { d: 'M1443,623.26s43.32-137.59,277-140.76c0,0,17.41,2.5,22.55,6.65,0,0,30.3,30.18,49.07,33.29C1791.65,522.44,1582.23,499.75,1443,623.26Z' } }, + { index: 5, tag: 'path', attributes: { d: 'M2653.21,623.26s-43.32-137.59-277-140.76c0,0-17.4,2.5-22.55,6.65,0,0-30.29,30.18-49.07,33.29C2304.6,522.44,2514,499.75,2653.21,623.26Z' } }, + { index: 8, tag: 'circle', attributes: { cx: 1922, cy: 808, r: 52 } }, + { index: 9, tag: 'circle', attributes: { cx: 2173, cy: 808, r: 52 } }, + { index: 10, tag: 'circle', attributes: { cx: 1602, cy: 816, r: 105 } }, + { index: 11, tag: 'circle', attributes: { cx: 2276, cy: 1073, r: 105 } }, + ...createDPadButtonVisuals(1816, 1091), + { index: 16, tag: 'circle', attributes: { cx: 2049, cy: 637, r: 65 } } + ], + sticks: [ + { buttonIndex: 10, axes: [0, 1], x: 1602, y: 816, range: 80 }, + { buttonIndex: 11, axes: [2, 3], x: 2276, y: 1073, range: 80 } + ] + }, + playstation: { + viewBox: '950 400 2200 1350', + imagePaths: { + Black: 'playstation/Controller Images/Solid/Solid Black SVG.svg', + White: 'playstation/Controller Images/Solid/Solid White SVG.svg' + }, + buttons: [ + { index: 0, tag: 'circle', attributes: { cx: 2668, cy: 966, r: 72 } }, + { index: 1, tag: 'circle', attributes: { cx: 2815, cy: 819, r: 72 } }, + { index: 2, tag: 'circle', attributes: { cx: 2521, cy: 819, r: 72 } }, + { index: 3, tag: 'circle', attributes: { cx: 2668, cy: 672, r: 72 } }, + { index: 4, tag: 'path', attributes: { d: 'M1306.92,530.22v-24s64.45-44.31,142-52.75,109.72,5,109.72,5a57.45,57.45,0,0,1,4.74,28.49S1402.82,497.78,1306.92,530.22Z' } }, + { index: 5, tag: 'path', attributes: { d: 'M2787.64,530.22v-24s-64.45-44.31-142-52.75-109.71,5-109.71,5a57.52,57.52,0,0,0-4.75,28.49S2691.74,497.78,2787.64,530.22Z' } }, + { index: 8, tag: 'rect', attributes: { x: 1545, y: 530, width: 80, height: 145, rx: 35, transform: 'rotate(-8 1585 602)' } }, + { index: 9, tag: 'rect', attributes: { x: 2470, y: 530, width: 80, height: 145, rx: 35, transform: 'rotate(8 2510 602)' } }, + { index: 10, tag: 'circle', attributes: { cx: 1724, cy: 1094, r: 115 } }, + { index: 11, tag: 'circle', attributes: { cx: 2370, cy: 1093, r: 115 } }, + ...createDPadButtonVisuals(1425, 819), + { index: 16, tag: 'circle', attributes: { cx: 2048, cy: 1094, r: 70 } }, + { index: 17, tag: 'rect', attributes: { x: 1650, y: 465, width: 795, height: 480, rx: 80 } } + ], + sticks: [ + { buttonIndex: 10, axes: [0, 1], x: 1724, y: 1094, range: 85 }, + { buttonIndex: 11, axes: [2, 3], x: 2370, y: 1093, range: 85 } + ] + }, + switch: { + viewBox: '1050 450 2050 1350', + imagePaths: { + Black: 'switch/Controller Images/Pro Controller/Solid/Pro Controller Solid Black SVG.svg', + White: 'switch/Controller Images/Pro Controller/Solid/Pro Controller Solid White SVG.svg' + }, + buttons: [ + { index: 0, tag: 'circle', attributes: { cx: 2501, cy: 956, r: 70 } }, + { index: 1, tag: 'circle', attributes: { cx: 2637, cy: 841, r: 70 } }, + { index: 2, tag: 'circle', attributes: { cx: 2371, cy: 841, r: 70 } }, + { index: 3, tag: 'circle', attributes: { cx: 2501, cy: 725, r: 70 } }, + { index: 4, tag: 'path', attributes: { d: 'M1404.59,610.8c43.07-43.91,147.53-67.61,260.35-80.32l5.36-.59.62-.07c39.26-4.29,79.4-7.26,118.24-9.31C1759,511,1719,487.24,1719,487.24l-20-3.16c-210.46-1.59-285.89,86-285.89,86l-28.81,52.62A132.91,132.91,0,0,0,1404.59,610.8Z' } }, + { index: 5, tag: 'path', attributes: { d: 'M2424.51,529.82l.63.07,5.36.59c112.82,12.71,217.28,36.41,260.35,80.32a135.13,135.13,0,0,0,22.22,12.74l-29.3-53.49s-75.43-87.56-285.89-86l-20,3.16s-40.23,23.87-70.39,33.34C2345.93,522.63,2385.65,525.57,2424.51,529.82Z' } }, + { index: 8, tag: 'circle', attributes: { cx: 1837, cy: 711, r: 50 } }, + { index: 9, tag: 'circle', attributes: { cx: 2260, cy: 711, r: 50 } }, + { index: 10, tag: 'circle', attributes: { cx: 1578, cy: 841, r: 105 } }, + { index: 11, tag: 'circle', attributes: { cx: 2275, cy: 1072, r: 105 } }, + ...createDPadButtonVisuals(1785, 1072), + { index: 16, tag: 'circle', attributes: { cx: 2172, cy: 841, r: 55 } }, + { index: 17, tag: 'rect', attributes: { x: 1876, y: 791, width: 105, height: 105, rx: 18 } } + ], + sticks: [ + { buttonIndex: 10, axes: [0, 1], x: 1578, y: 841, range: 80 }, + { buttonIndex: 11, axes: [2, 3], x: 2275, y: 1072, range: 80 } + ] + } + }; // Check if the Gamepad API is supported if (!gamepadHelper.isSupported()) { @@ -24,6 +118,7 @@ document.addEventListener('DOMContentLoaded', function() { // Theme changed, reinitialize buttons with a new color scheme if (activeGamepadIndex !== null) { initGamepadButtons(); + initGamepadVisual(); } } }); @@ -53,9 +148,6 @@ document.addEventListener('DOMContentLoaded', function() { globalThis.addEventListener("gamepaddisconnected", function(e) { delete gamepads[e.gamepad.index]; - updateGamepadSelector(); - - updateStatus(`Gamepad ${e.gamepad.id} disconnected`); // If the active gamepad was disconnected if (activeGamepadIndex === e.gamepad.index) { @@ -69,6 +161,9 @@ document.addEventListener('DOMContentLoaded', function() { stopGamepadLoop(); } } + + updateGamepadSelector(); + updateStatus(`Gamepad ${e.gamepad.id} disconnected`); }); // Event delegation for gamepad selector cards @@ -153,10 +248,11 @@ document.addEventListener('DOMContentLoaded', function() { gamepadSelector.appendChild(card); }); - // Initialize buttons and axes for the selected gamepad + // Initialize controls for the selected gamepad if (activeGamepadIndex !== null) { initGamepadButtons(); initGamepadAxes(); + initGamepadVisual(); } } else { gamepadSelectorContainer.style.display = 'none'; @@ -164,6 +260,156 @@ document.addEventListener('DOMContentLoaded', function() { } } + function createSvgElement(tag, attributes = {}) { + const element = document.createElementNS(svgNamespace, tag); + + Object.entries(attributes).forEach(([name, value]) => { + element.setAttribute(name, value); + }); + + return element; + } + + function showVisualUnavailable(visualContainer) { + const message = document.createElement('span'); + message.className = 'text-muted'; + message.textContent = 'A visual is not available for this controller.'; + visualContainer.replaceChildren(message); + } + + function getGamepadVisualImagePath(config) { + const isDarkTheme = document.documentElement.dataset.bsTheme === 'dark'; + const colorScheme = isDarkTheme ? 'White' : 'Black'; + const encodedPath = config.imagePaths[colorScheme] + .split('/') + .map(pathPart => encodeURIComponent(pathPart)) + .join('/'); + + return gamepadAssetBasePath + encodedPath; + } + + function createTriggerVisual(controllerType, buttonIndex, colorScheme) { + const buttonName = gamepadHelper.getButtonName(controllerType, buttonIndex); + const trigger = document.createElement('div'); + trigger.className = 'gamepad-trigger-visual'; + trigger.setAttribute('role', 'progressbar'); + trigger.setAttribute('aria-label', `${buttonName} trigger pressure`); + trigger.setAttribute('aria-valuemin', '0'); + trigger.setAttribute('aria-valuemax', '1'); + trigger.setAttribute('aria-valuenow', '0'); + + const fill = document.createElement('span'); + fill.className = 'gamepad-trigger-fill'; + trigger.appendChild(fill); + + const buttonImagePath = gamepadHelper.getButtonImagePath( + controllerType, + buttonIndex, + gamepadAssetBasePath, + colorScheme + ); + const buttonImage = document.createElement('img'); + buttonImage.className = 'gamepad-trigger-image'; + buttonImage.src = buttonImagePath; + buttonImage.alt = buttonName; + trigger.appendChild(buttonImage); + + const fallback = document.createElement('span'); + fallback.className = 'gamepad-trigger-name'; + fallback.textContent = buttonName; + trigger.appendChild(fallback); + + buttonImage.addEventListener('error', function() { + buttonImage.classList.add('d-none'); + fallback.classList.add('visible'); + }, { once: true }); + + const value = document.createElement('span'); + value.className = 'gamepad-trigger-value'; + value.textContent = '0.00'; + trigger.appendChild(value); + + gamepadVisualTriggers.set(buttonIndex, { element: trigger, value }); + return trigger; + } + + function initGamepadVisual() { + const visualContainer = document.getElementById('controller-visual'); + gamepadVisualButtons = new Map(); + gamepadVisualSticks = []; + gamepadVisualTriggers = new Map(); + visualContainer.replaceChildren(); + + if (activeGamepadIndex === null) { + showVisualUnavailable(visualContainer); + return; + } + + const gamepad = navigator.getGamepads()[activeGamepadIndex]; + if (!gamepad) { + showVisualUnavailable(visualContainer); + return; + } + + const gamepadInfo = gamepadHelper.getGamepadInfo(gamepad.id); + const config = gamepadVisualConfigs[gamepadInfo.type]; + if (!config) { + showVisualUnavailable(visualContainer); + return; + } + + const isDarkTheme = document.documentElement.dataset.bsTheme === 'dark'; + const colorScheme = isDarkTheme ? 'White' : 'Black'; + visualContainer.appendChild(createTriggerVisual(gamepadInfo.type, 6, colorScheme)); + + const visual = createSvgElement('svg', { + class: 'gamepad-visual-svg', + viewBox: config.viewBox, + role: 'img', + 'aria-label': `${gamepadInfo.name} input visual` + }); + const title = createSvgElement('title'); + title.textContent = `${gamepadInfo.name} input visual`; + visual.appendChild(title); + + const controllerImage = createSvgElement('image', { + href: getGamepadVisualImagePath(config), + width: 4096, + height: 2160, + preserveAspectRatio: 'xMidYMid meet' + }); + controllerImage.addEventListener('error', function() { + showVisualUnavailable(visualContainer); + }, { once: true }); + visual.appendChild(controllerImage); + + config.buttons.forEach(button => { + const control = createSvgElement(button.tag, button.attributes); + control.classList.add('gamepad-visual-control'); + control.dataset.buttonIndex = button.index; + visual.appendChild(control); + gamepadVisualButtons.set(button.index, control); + }); + + config.sticks.forEach(stick => { + const indicator = createSvgElement('circle', { + class: 'gamepad-stick-indicator', + cx: stick.x, + cy: stick.y, + r: 28 + }); + visual.appendChild(indicator); + gamepadVisualSticks.push({ + ...stick, + buttonElement: gamepadVisualButtons.get(stick.buttonIndex), + indicator + }); + }); + + visualContainer.appendChild(visual); + visualContainer.appendChild(createTriggerVisual(gamepadInfo.type, 7, colorScheme)); + } + // Initialize gamepad button UI elements function initGamepadButtons() { const buttonsContainer = document.getElementById('buttons-container'); @@ -372,6 +618,37 @@ document.addEventListener('DOMContentLoaded', function() { } } + function updateGamepadVisual(gamepad) { + for (let i = 0; i < gamepad.buttons.length; i++) { + const control = gamepadVisualButtons.get(i); + if (!control) continue; + + const button = gamepad.buttons[i]; + const isActive = button.pressed || button.value > 0.1; + const opacity = Math.max(0.35, button.value * 0.9); + control.classList.toggle('active', isActive); + control.style.fillOpacity = isActive ? opacity.toFixed(2) : '0'; + } + + gamepadVisualTriggers.forEach((trigger, buttonIndex) => { + const button = gamepad.buttons[buttonIndex]; + const value = Math.max(0, Math.min(1, button?.value || 0)); + const isActive = Boolean(button?.pressed) || value > 0.1; + trigger.element.classList.toggle('active', isActive); + trigger.element.style.setProperty('--gamepad-trigger-value', `${(value * 100).toFixed(0)}%`); + trigger.element.setAttribute('aria-valuenow', value.toFixed(2)); + trigger.value.textContent = value.toFixed(2); + }); + + gamepadVisualSticks.forEach(stick => { + const horizontalValue = Math.max(-1, Math.min(1, gamepad.axes[stick.axes[0]] || 0)); + const verticalValue = Math.max(-1, Math.min(1, gamepad.axes[stick.axes[1]] || 0)); + const transform = `translate(${(horizontalValue * stick.range).toFixed(1)} ${(verticalValue * stick.range).toFixed(1)})`; + stick.buttonElement.setAttribute('transform', transform); + stick.indicator.setAttribute('transform', transform); + }); + } + // Update axes UI states function updateAxes(gamepad) { for (let i = 0; i < gamepad.axes.length; i++) { @@ -510,6 +787,7 @@ document.addEventListener('DOMContentLoaded', function() { if (activeGamepadIndex !== null) { initGamepadButtons(); initGamepadAxes(); + initGamepadVisual(); } gamepadLoop(); @@ -534,6 +812,7 @@ document.addEventListener('DOMContentLoaded', function() { // Update all the UI elements updateGamepadInfo(gamepad); updateButtons(gamepad); + updateGamepadVisual(gamepad); updateAxes(gamepad); updateRawData(gamepad); } diff --git a/gamepad-tester.html b/gamepad-tester.html index 9a33ab7f..de4ed9d7 100644 --- a/gamepad-tester.html +++ b/gamepad-tester.html @@ -33,6 +33,19 @@