Files
whowechina_iidx_pico/Tools/gamepad_test.html
T

2260 lines
96 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>whowechina Series Controller Test</title>
<style>
body {
font-family: 'Segoe UI', Arial, sans-serif;
margin: 20px;
background-color: #f5f5f5;
position: relative;
}
.container {
margin: 0 auto;
position: relative;
padding: 0 10px;
}
.lang-switch {
position: absolute;
top: 0;
right: 0;
display: flex;
gap: 10px;
z-index: 1000;
}
.lang-btn {
width: 40px;
height: 40px;
cursor: pointer;
opacity: 0.5;
transition: all 0.3s ease;
border: 2px solid transparent;
background-size: cover;
background-position: center;
border-radius: 5px;
padding: 0;
}
.lang-btn.lang-en-btn {
background-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 190 100"%3E%3Cg%3E%3Crect width="190" height="7.69" fill="%23B22234"/%3E%3Crect width="190" height="7.69" y="7.69" fill="white"/%3E%3Crect width="190" height="7.69" y="15.38" fill="%23B22234"/%3E%3Crect width="190" height="7.69" y="23.07" fill="white"/%3E%3Crect width="190" height="7.69" y="30.76" fill="%23B22234"/%3E%3Crect width="190" height="7.69" y="38.45" fill="white"/%3E%3Crect width="190" height="7.69" y="46.14" fill="%23B22234"/%3E%3Crect width="190" height="7.69" y="53.83" fill="white"/%3E%3Crect width="190" height="7.69" y="61.52" fill="%23B22234"/%3E%3Crect width="190" height="7.69" y="69.21" fill="white"/%3E%3Crect width="190" height="7.69" y="76.9" fill="%23B22234"/%3E%3Crect width="190" height="7.69" y="84.59" fill="white"/%3E%3Crect width="190" height="7.69" y="92.28" fill="%23B22234"/%3E%3Crect width="76" height="53.85" fill="%233C3B6E"/%3E%3C/g%3E%3C/svg%3E');
}
.lang-btn.lang-zh-btn {
background-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 200"%3E%3Crect width="300" height="200" fill="%23de2910"/%3E%3Cg fill="%23ffde00"%3E%3Cpath d="M60,30 l20,60 l-50,-40 h62 l-50,40 z"/%3E%3Cpath d="M120,10 l5,15 l-12,-10 h15 l-12,10 z"/%3E%3Cpath d="M140,25 l5,15 l-12,-10 h15 l-12,10 z"/%3E%3Cpath d="M140,50 l5,15 l-12,-10 h15 l-12,10 z"/%3E%3Cpath d="M120,65 l5,15 l-12,-10 h15 l-12,10 z"/%3E%3C/g%3E%3C/svg%3E');
}
.lang-btn:hover {
opacity: 0.8;
transform: scale(1.05);
border-color: #007bff;
}
.lang-btn.active {
opacity: 1;
transform: scale(1.1);
border-color: #007bff;
box-shadow: 0 0 10px rgba(0, 123, 255, 0.5);
}
.lang-en { display: inline; }
.lang-zh { display: none; }
body.zh .lang-en { display: none; }
body.zh .lang-zh { display: inline; }
h1 {
text-align: center;
color: #333;
margin-bottom: 30px;
}
.status {
text-align: center;
font-size: 18px;
margin-bottom: 20px;
padding: 10px;
border-radius: 5px;
}
.status.connected {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status.disconnected {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.controls {
text-align: center;
margin-bottom: 30px;
}
.reset-btn {
background-color: #dc3545;
color: white;
border: none;
padding: 12px 24px;
font-size: 16px;
border-radius: 5px;
cursor: pointer;
margin: 0 10px;
}
.reset-btn:hover {
background-color: #c82333;
}
.gamepad {
background: white;
border-radius: 10px;
padding: 20px;
margin-bottom: 30px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.gamepad-title {
font-size: 20px;
font-weight: bold;
margin-bottom: 15px;
color: #333;
border-bottom: 2px solid #007bff;
padding-bottom: 10px;
}
.buttons-container {
display: flex;
flex-wrap: wrap;
gap: 15px;
justify-content: flex-start;
margin-bottom: 20px;
padding: 20px;
background: #f8f9fa;
border-radius: 10px;
}
.button-circle {
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(145deg, #e9ecef, #ffffff);
border: 3px solid #dee2e6;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.066s ease;
position: relative;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.button-circle:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
}
.button-circle.pressed {
background: linear-gradient(145deg, #28a745, #34ce57);
border-color: #1e7e34;
color: white;
transform: scale(1.1);
box-shadow: 0 0 20px rgba(40, 167, 69, 0.5);
}
.button-name {
font-weight: bold;
font-size: 10px;
margin-bottom: 3px;
text-align: center;
line-height: 1;
}
.button-count {
font-size: 16px;
font-weight: bold;
color: #007bff;
}
.button-circle.pressed .button-count {
color: white;
}
/* 特殊按钮颜色 */
.button-circle.action {
background: linear-gradient(145deg, #ffeaa7, #fdcb6e);
border-color: #e17055;
}
.button-circle.shoulder {
background: linear-gradient(145deg, #a29bfe, #6c5ce7);
color: white;
}
.button-circle.dpad {
background: linear-gradient(145deg, #fd79a8, #e84393);
color: white;
}
.button-circle.system {
background: linear-gradient(145deg, #636e72, #2d3436);
color: white;
}
.axes-section {
margin-top: 20px;
}
.axes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
gap: 10px;
}
.axis-item {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 5px;
padding: 5px 3px;
text-align: center;
}
.axis-name {
font-weight: bold;
font-size: 12px;
margin-bottom: 12px;
}
.axis-value {
font-size: 11px;
font-family: monospace;
margin-top: 5px;
color: #495057;
}
.axis-bar {
width: 35%;
height: 100px;
background: linear-gradient(to bottom, #e9ecef 0%, #f8f9fa 50%, #e9ecef 100%);
border: 1px solid #ced4da;
border-radius: 4px;
position: relative;
margin: 5px auto;
}
.axis-indicator {
position: absolute;
width: 200%;
height: 6px;
background: #007bff;
border-radius: 3px;
box-shadow: 0 0 4px rgba(0, 123, 255, 0.5);
left: -50%;
z-index: 10;
}
.axis-trail {
position: absolute;
width: 200%;
height: 4px;
background: #007bff;
border-radius: 2px;
left: -50%;
pointer-events: none;
transition: opacity 0.6s ease;
}
.axis-center-line {
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
background: #6c757d;
transform: translateY(-50%);
}
.hat-item {
background: #f8f9fa;
grid-column: span 2;
}
.hat-name {
font-weight: bold;
font-size: 12px;
margin-bottom: 8px;
}
.hat-pad {
width: 96px;
height: 96px;
margin: 5px auto;
position: relative;
background: linear-gradient(135deg, #e9ecef 0%, #f8f9fa 100%);
clip-path: polygon(30% 0%, 70% 0%, 100% 30%, 100% 70%, 70% 100%, 30% 100%, 0% 70%, 0% 30%);
}
.hat-dir {
position: absolute;
width: 20px;
height: 20px;
border-radius: 50%;
border: 1px solid #adb5bd;
background: #ffffff;
color: #6c757d;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
transform: translate(-50%, -50%);
transition: all 0.08s ease;
}
.hat-dir.active {
background: #007bff;
border-color: #0056b3;
color: #ffffff;
box-shadow: 0 0 8px rgba(0, 123, 255, 0.55);
transform: translate(-50%, -50%) scale(1.08);
}
.hat-center {
position: absolute;
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid #adb5bd;
background: #f1f3f5;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
transition: all 0.08s ease;
}
.hat-center.active {
background: #20c997;
border-color: #138a63;
box-shadow: 0 0 8px rgba(32, 201, 151, 0.45);
}
.hat-value {
font-size: 11px;
font-family: monospace;
margin-top: 6px;
color: #495057;
min-height: 20px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
line-height: 1.2;
}
@media (max-width: 540px) {
.hat-item {
grid-column: span 1;
}
}
.stick-2d {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 5px;
padding: 5px 3px;
text-align: center;
grid-column: span 2;
}
.stick-2d-canvas {
width: 100px;
height: 100px;
background: linear-gradient(135deg, #e9ecef 0%, #f8f9fa 100%);
border: 2px solid #ced4da;
border-radius: 50%;
position: relative;
margin: 5px auto;
}
.stick-2d-center {
position: absolute;
top: 50%;
left: 50%;
width: 2px;
height: 2px;
background: #6c757d;
transform: translate(-50%, -50%);
}
.stick-2d-cross-h {
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
background: #adb5bd;
transform: translateY(-50%);
}
.stick-2d-cross-v {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1px;
background: #adb5bd;
transform: translateX(-50%);
}
.stick-2d-indicator {
position: absolute;
width: 16px;
height: 16px;
background: #007bff;
border: 2px solid #fff;
border-radius: 50%;
box-shadow: 0 0 8px rgba(0, 123, 255, 0.6);
transform: translate(-50%, -50%);
z-index: 10;
}
.stick-2d-trail {
position: absolute;
width: 14px;
height: 14px;
border-radius: 50%;
background: #007bff;
transform: translate(-50%, -50%);
pointer-events: none;
transition: opacity 0.6s ease;
}
.stick-2d-value {
font-size: 11px;
font-family: monospace;
margin-top: 5px;
color: #495057;
}
.button-group {
margin-bottom: 20px;
}
.button-group-title {
font-size: 16px;
font-weight: bold;
color: #333;
margin-bottom: 10px;
text-align: left;
}
/* HID LED 控制面板样式 */
.hid-panel {
background: white;
border-radius: 10px;
padding: 20px;
margin-bottom: 30px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.hid-title {
font-size: 20px;
font-weight: bold;
margin-bottom: 15px;
color: #333;
border-bottom: 2px solid #ffc107;
padding-bottom: 10px;
}
.hid-status {
margin-bottom: 15px;
padding: 10px;
border-radius: 5px;
font-size: 14px;
text-align: center;
}
.hid-status.connected {
background-color: #d4edda;
color: #155724;
}
.hid-status.disconnected {
background-color: #f8d7da;
color: #721c24;
}
.hid-connect-btn {
display: block;
margin: 0 auto 15px;
background-color: #ffc107;
color: #333;
border: none;
padding: 12px 24px;
font-size: 16px;
font-weight: bold;
border-radius: 5px;
cursor: pointer;
}
.hid-connect-btn:hover {
background-color: #e0a800;
}
.led-grid {
display: flex;
flex-direction: column;
gap: 15px;
align-items: center;
padding: 20px;
background: #2a2a2a;
border-radius: 10px;
width: 100%;
max-width: 1200px;
margin: 0 auto;
}
.led-button {
width: 72px;
height: 72px;
border-radius: 8px;
border: 3px solid #343a40;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
cursor: pointer;
/* 只对 transform 添加过渡,不包括 box-shadow(光晕) */
transition: transform 0.2s ease;
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
color: white;
font-size: 11px;
font-weight: bold;
text-align: center;
padding: 5px;
}
/* 形状变体 */
.led-button.shape-circle {
border-radius: 50%;
}
.led-button.shape-rectangle {
border-radius: 8px;
}
.led-button.rgb-type {
position: relative;
padding: 5px; /* 上下左右完全对称,保持圆形 */
}
.led-button.rgb-type .led-name {
position: absolute;
top: 8px;
left: 50%;
transform: translateX(-50%);
width: 90%;
text-align: center;
}
.led-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(0,0,0,0.3);
}
.led-button.rgb-type:hover {
transform: none;
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
}
.led-button.on {
background: linear-gradient(145deg, #ffc107, #ff9800);
border-color: #ff6f00;
box-shadow: 0 0 20px rgba(255, 193, 7, 0.6), 0 4px 8px rgba(0,0,0,0.2);
color: #000;
}
.led-button.dragging {
cursor: ns-resize;
/* 拖拽时维持悬停效果 */
transform: translateY(-2px);
}
.brightness-indicator {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
font-size: 9px;
background: rgba(0,0,0,0.6);
padding: 2px 6px;
border-radius: 3px;
color: white;
font-weight: bold;
pointer-events: none;
margin: 0;
}
.led-icon {
font-size: 29px;
margin: 0 0 2px 0;
}
.led-name {
font-size: 11px;
line-height: 1;
word-wrap: break-word;
margin: 0 0 2px 0;
font-weight: bold;
}
.rgb-sliders {
display: flex;
justify-content: center;
align-items: stretch;
width: 75%;
height: 60%;
margin-left: auto;
margin-right: auto;
margin-top: calc(50% - 30% - 6px); /* 50%是中心,-30%是容器半高,-6px是标题半高 */
flex-direction: column;
}
.rgb-sliders-row {
display: flex;
justify-content: space-between;
height: 100%;
width: 100%;
}
.rgb-slider-container {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
margin: 0 2%;
}
.rgb-slider-title {
font-size: 10px;
font-weight: bold;
margin-bottom: 2px;
color: #666;
}
.rgb-slider {
width: 100%;
flex: 1;
background: linear-gradient(to top, #333, #666);
border-radius: 3px;
position: relative;
cursor: pointer;
border: 1px solid rgba(255,255,255,0.3);
}
.rgb-slider-fill {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
border-radius: 3px;
}
.rgb-slider.r .rgb-slider-fill {
background: linear-gradient(to top, #000, #f00);
}
.rgb-slider.g .rgb-slider-fill {
background: linear-gradient(to top, #000, #0f0);
}
.rgb-slider.b .rgb-slider-fill {
background: linear-gradient(to top, #000, #00f);
}
.rgb-slider-value {
position: absolute;
bottom: 2px;
left: 50%;
transform: translateX(-50%);
font-size: 8px;
background: rgba(0,0,0,0.6);
padding: 1px 4px;
border-radius: 2px;
color: white;
font-weight: bold;
pointer-events: none;
min-width: 20px;
text-align: center;
}
</style>
</head>
<body>
<div class="container">
<div class="lang-switch">
<button class="lang-btn lang-en-btn active" id="lang-en" onclick="switchLanguage('en')" title="English"></button>
<button class="lang-btn lang-zh-btn" id="lang-zh" onclick="switchLanguage('zh')" title="中文"></button>
</div>
<h1>🎮 <span class="lang-en"><a href="https://github.com/whowechina" target="_blank" style="color: inherit; text-decoration: none;">whowechina</a> Series Controller Test</span><span class="lang-zh"><a href="https://github.com/whowechina" target="_blank" style="color: inherit; text-decoration: none;">whowechina</a> 系列手台测试</span></h1>
<div id="status" class="status disconnected">
<span class="lang-en">Please connect a gamepad and press any button to activate</span><span class="lang-zh">请连接游戏手柄并按任意按钮激活</span>
</div>
<div id="gamepads"></div>
<!-- HID LED 控制面板 -->
<div class="hid-panel">
<div class="hid-title">💡 <span class="lang-en">HID Light Control</span><span class="lang-zh">HID 灯光控制</span></div>
<div id="hid-status" class="hid-status disconnected">
<span class="lang-en">Click button to connect HID device</span><span class="lang-zh">点击按钮连接 HID 设备</span>
</div>
<button class="hid-connect-btn" id="hid-connect-btn" onclick="toggleHIDConnection()">🔌 <span class="lang-en">Connect HID Device</span><span class="lang-zh">连接 HID 设备</span></button>
<div class="led-grid" id="led-container">
<!-- LED 按钮将动态生成 -->
</div>
</div>
</div>
<script>
// 语言切换
function switchLanguage(lang) {
if (lang === 'zh') {
document.body.classList.add('zh');
document.getElementById('lang-en').classList.remove('active');
document.getElementById('lang-zh').classList.add('active');
} else {
document.body.classList.remove('zh');
document.getElementById('lang-en').classList.add('active');
document.getElementById('lang-zh').classList.remove('active');
}
}
// 全局状态
let gamepads = {};
let buttonStates = {};
let buttonCounts = {};
let stickTrails = {}; // 存储摇杆拖尾历史
let axisDisplayModes = {}; // 存储轴显示模式:axis 或 hat
let hatAxisLocks = {}; // 存储已锁定为 HAT 的轴
let isGamepadSupported = 'getGamepads' in navigator;
// HID 相关变量
let hidDevice = null;
let ledStates = {}; // 存储每个 LED 的状态 { ledIndex: { reportId, value } }
let outputReports = new Map(); // 存储每个 report ID 的信息 { reportId: { size, data } }
let hidUpdateTimer = null; // 定期发送定时器
let currentLedMapping = null; // 当前设备的LED映射配置
// LED 映射配置 - 根据设备名称定义灯光布局
const ledMappingConfig = {
"IIDX Pico Controller": {
lights: [
{ name: "E1", index: 7, x: 10, y: 8, color: "red", shape: "rectangle", size: 50},
{ name: "E2", index: 8, x: 20, y: 8, color: "green", shape: "rectangle", size: 50},
{ name: "E3", index: 9, x: 30, y: 8, color: "green", shape: "rectangle", size: 50},
{ name: "E4", index: 10, x: 40, y: 8, color: "green", shape: "rectangle", size: 50},
{ name: "B2", index: 1, x: 15, y: 28, color: "blue", shape: "rectangle", ratio: 1.6, size: 65},
{ name: "B4", index: 3, x: 27, y: 28, color: "blue", shape: "rectangle", ratio: 1.6, size: 65},
{ name: "B6", index: 5, x: 39, y: 28, color: "blue", shape: "rectangle", ratio: 1.6, size: 65},
{ name: "B1", index: 0, x: 9, y: 43, color: "white", shape: "rectangle", ratio: 1.6, size: 65},
{ name: "B3", index: 2, x: 21, y: 43, color: "white", shape: "rectangle", ratio: 1.6, size: 65},
{ name: "B5", index: 4, x: 33, y: 43, color: "white", shape: "rectangle", ratio: 1.6, size: 65},
{ name: "B7", index: 6, x: 45, y: 43, color: "white", shape: "rectangle", ratio: 1.6, size: 65},
{ name: "TT", index: 11, x: 72, y: 28, color: "rgb", shape: "circle", size: 300},
]
},
"AIC Pico LED": {
lights: [
{ name: "AIC Pico", index: 0, x: 15, y: 15, color: "rgb", shape: "circle", size: 200 }
]
},
"Popn Pico": {
lights: [
{ name: "B2", index: 1, x: 14, y: 8, color: "yellow", shape: "circle", size: 95},
{ name: "B4", index: 3, x: 29, y: 8, color: "blue", shape: "circle", size: 95},
{ name: "B6", index: 5, x: 44, y: 8, color: "blue", shape: "circle", size: 95},
{ name: "B8", index: 7, x: 59, y: 8, color: "yellow", shape: "circle", size: 95},
{ name: "B1", index: 0, x: 9, y: 23, color: "white", shape: "circle", size: 95},
{ name: "B3", index: 2, x: 24, y: 23, color: "green", shape: "circle", size: 95},
{ name: "B5", index: 4, x: 39, y: 23, color: "red", shape: "circle", size: 95},
{ name: "B7", index: 6, x: 54, y: 23, color: "green", shape: "circle", size: 95},
{ name: "B9", index: 8, x: 69, y: 23, color: "white", shape: "circle", size: 95},
{ name: "Cabnet", index: 9, x: 88, y: 15, color: "rgb", shape: "rectangle", ratio: 1.6, size: 200},
]
},
"Diva Pico LED": {
lights: [
{ name: "G", index: 93, count: 32, x: 2, y: 3, di: -3, dx: 3, dy: 0, color: "green", shape: "rectangle", ratio: 2.4, size: 18},
{ name: "R", index: 94, count: 32, x: 2, y: 8.5, di: -3, dx: 3, dy: 0, color: "red", shape: "rectangle", ratio: 2.4, size: 18},
{ name: "B", index: 95, count: 32, x: 2, y: 14, di: -3, dx: 3, dy: 0, color: "blue", shape: "rectangle", ratio: 2.4, size: 18},
{ name: "△", index: 96, x: 16, y: 25, color: "green", shape: "circle", size: 95},
{ name: "▢", index: 97, x: 36, y: 25, color: "pink", shape: "circle", size: 95},
{ name: "✕", index: 98, x: 56, y: 25, color: "blue", shape: "circle", size: 95},
{ name: "◯", index: 99, x: 76, y: 25, color: "red", shape: "circle", size: 95},
]
}, // 可以在此添加更多设备的配置
// 支持的形状: "rectangle" (矩形), "circle" (圆形)
// ratio: 高宽比,仅rectangle有效,默认1(正方形),1.6表示高度是宽度的1.6倍
// size: 缩放因子,默认100,基础大小为容器宽度的10%,size=100即10%,size=50即5%,size=200即20%
// x: 横向位置,范围0-100,0为最左,100为最右(百分比)
// y: 纵向位置,范围0-100,相对于容器宽度计算(保持1:1宽高比)
};
// 初始化
if (!isGamepadSupported) {
document.getElementById('status').innerHTML = '<span class="lang-en">Your browser does not support Gamepad API</span><span class="lang-zh">您的浏览器不支持 Gamepad API</span>';
document.getElementById('status').className = 'status disconnected';
}
// 检查 WebHID 支持
if (!('hid' in navigator)) {
const hidStatus = document.getElementById('hid-status');
hidStatus.innerHTML = '<span class="lang-en">WebHID API not supported</span><span class="lang-zh">不支持 WebHID API</span>';
hidStatus.className = 'hid-status disconnected';
}
// ========== HID 功能 ==========
function toggleHIDConnection() {
if (hidDevice) {
disconnectHIDDevice();
} else {
connectHIDDevice();
}
}
function disconnectHIDDevice() {
if (!hidDevice) return;
// 清除定时器
if (hidUpdateTimer) {
clearInterval(hidUpdateTimer);
hidUpdateTimer = null;
}
hidDevice.close();
hidDevice = null;
ledStates = {};
// 更新状态
document.getElementById('hid-status').innerHTML =
'<span class="lang-en">Click button to connect HID device</span><span class="lang-zh">点击按钮连接 HID 设备</span>';
document.getElementById('hid-status').className = 'hid-status disconnected';
// 清空LED容器并重置高度
const container = document.getElementById('led-container');
container.innerHTML = '';
container.style.minHeight = '';
// 更新按钮文本
document.getElementById('hid-connect-btn').innerHTML =
'🔌 <span class="lang-en">Connect HID Device</span><span class="lang-zh">连接 HID 设备</span>';
}
async function connectHIDDevice() {
try {
const devices = await navigator.hid.requestDevice({ filters: [] });
if (devices.length === 0) {
return;
}
hidDevice = devices[0];
await hidDevice.open();
console.log('HID Device connected:', hidDevice);
// 获取设备名称并查找映射配置
const deviceName = hidDevice.productName || 'HID Device';
console.log('Device productName:', JSON.stringify(deviceName));
console.log('Available mappings:', Object.keys(ledMappingConfig));
currentLedMapping = ledMappingConfig[deviceName] || null;
if (currentLedMapping) {
console.log('✅ Found LED mapping for device:', deviceName);
} else {
console.log('⚠️ No LED mapping found for device:', deviceName);
console.log('Using default layout');
}
// 解析 Output Report
parseOutputReports();
// 更新状态
document.getElementById('hid-status').innerHTML =
`✅ <span class="lang-en">Connected: ${deviceName}</span><span class="lang-zh">已连接: ${deviceName}</span>`;
document.getElementById('hid-status').className = 'hid-status connected';
// 更新按钮文本
document.getElementById('hid-connect-btn').innerHTML =
'🔌 <span class="lang-en">Disconnect</span><span class="lang-zh">断开</span>';
// 启动定时发送,每250ms(每秒4次)
if (hidUpdateTimer) clearInterval(hidUpdateTimer);
hidUpdateTimer = setInterval(() => {
if (hidDevice) {
sendOutputReport();
}
}, 250);
// 监听断开
hidDevice.addEventListener('disconnect', () => {
// 清除定时器
if (hidUpdateTimer) {
clearInterval(hidUpdateTimer);
hidUpdateTimer = null;
}
document.getElementById('hid-status').innerHTML =
'<span class="lang-en">Device disconnected</span><span class="lang-zh">设备已断开</span>';
document.getElementById('hid-status').className = 'hid-status disconnected';
document.getElementById('led-container').innerHTML = '';
hidDevice = null;
ledStates = {};
// 更新按钮文本
document.getElementById('hid-connect-btn').innerHTML =
'🔌 <span class="lang-en">Connect HID Device</span><span class="lang-zh">连接 HID 设备</span>';
});
} catch (error) {
console.error('HID connection error:', error);
const isZh = document.body.classList.contains('zh');
alert(isZh ? `连接失败: ${error.message}` : `Failed to connect HID device: ${error.message}`);
}
}
function parseOutputReports() {
const container = document.getElementById('led-container');
container.innerHTML = '';
ledStates = {};
outputReports.clear();
let ledIndex = 0;
hidDevice.collections.forEach((collection) => {
if (!collection.outputReports || collection.outputReports.length === 0) {
return;
}
collection.outputReports.forEach((report) => {
const reportId = report.reportId;
if (!report.items || report.items.length === 0) {
return;
}
// 计算报告总大小
let totalBits = 0;
report.items.forEach(item => {
totalBits += (item.reportSize || 0) * (item.reportCount || 1);
});
const reportSize = Math.ceil(totalBits / 8);
// 存储该report ID的信息
outputReports.set(reportId, {
size: reportSize,
data: new Uint8Array(reportSize)
});
console.log(`📋 Found output report ID ${reportId}, size: ${reportSize} bytes`);
// 为该report的每个LED初始化状态(offset是相对于该report的)
let offsetInReport = 0;
report.items.forEach(item => {
const count = item.reportCount || 1;
for (let i = 0; i < count; i++) {
ledStates[ledIndex] = { reportId: reportId, offset: offsetInReport, value: 0 };
ledIndex++;
offsetInReport++;
}
});
});
});
// 使用映射配置创建LED按钮
console.log('Checking mapping:', currentLedMapping);
console.log('Has lights?', currentLedMapping && currentLedMapping.lights);
// 将容器设置为相对定位,用于绝对定位按钮(对所有情况都需要)
container.style.position = 'relative';
// 设置CSS变量存储容器宽度,用于坐标计算(对所有情况都需要)
const updateContainerWidth = () => {
const width = container.offsetWidth;
container.style.setProperty('--container-width', `${width}px`);
};
updateContainerWidth();
window.addEventListener('resize', updateContainerWidth);
if (currentLedMapping && currentLedMapping.lights) {
console.log('🎨 Applying LED mapping with', currentLedMapping.lights.length, 'lights');
console.log('Lights data:', currentLedMapping.lights);
// 计算所需的最小高度(找到Y+按钮高度的最大值)
const calculateMinHeight = () => {
let maxBottom = 0;
currentLedMapping.lights.forEach((led) => {
const basePercent = 10;
const actualSize = basePercent * (led.size || 100) / 100;
let heightSize = actualSize;
if (led.shape === 'rectangle') {
const ratio = led.ratio || 1;
heightSize = actualSize * ratio;
}
const y = led.y || 0;
const bottom = y + heightSize;
maxBottom = Math.max(maxBottom, bottom);
});
// 加上余量(额外5%的高度)
const minHeight = (maxBottom + 5) * container.offsetWidth / 100;
container.style.minHeight = `${minHeight}px`;
};
// 初始计算和监听窗口变化
setTimeout(calculateMinHeight, 0);
window.addEventListener('resize', calculateMinHeight);
try {
currentLedMapping.lights.forEach((led, ledIndex) => {
console.log('Creating LED', ledIndex, ':', led);
// 处理count属性,创建多个连续的LED
const count = led.count || 1;
const dx = led.dx !== undefined ? led.dx : (led.x || 0) * 0.1;
const dy = led.dy !== undefined ? led.dy : (led.y || 0) * 0.1;
const di = led.di !== undefined ? led.di : (led.color === 'rgb' ? 3 : 1);
for (let i = 0; i < count; i++) {
// 计算当前LED的属性
let currentIndex = Array.isArray(led.index)
? led.index.map(idx => idx + i * di)
: led.index + i * di;
// 如果原始name为空,保持为空;否则根据count添加序号
// 确保name是字符串类型
const baseName = String(led.name || '');
const currentName = baseName ? (count > 1 ? `${baseName} ${i + 1}` : baseName) : '';
const currentX = (led.x || 0) + i * dx;
const currentY = (led.y || 0) + i * dy;
if (led.color === 'rgb') {
// RGB 类型:确保index是数组
if (!Array.isArray(currentIndex)) {
currentIndex = [currentIndex, currentIndex + 1, currentIndex + 2];
}
// 创建一个按钮,控制三个通道
// 如果currentName为空,使用索引作为groupId
const rgbGroupId = currentName || `rgb-${currentIndex[0]}`;
const button = createLEDButton(
currentIndex, // 传递整个数组
currentName,
'rgb',
null,
rgbGroupId,
led.shape,
false,
led.size,
currentX,
currentY,
led.ratio
);
console.log(' Created RGB button:', currentName, 'with index', currentIndex);
if (button) {
container.appendChild(button);
updateLEDVisual(currentIndex, 'rgb', null);
}
} else {
// 普通单色LED
const button = createLEDButton(
currentIndex,
currentName,
led.color || 'white',
null,
null,
led.shape,
false,
led.size,
currentX,
currentY,
led.ratio
);
console.log(' Created', led.color || 'white', 'button:', currentName);
if (button) {
container.appendChild(button);
updateLEDVisual(currentIndex, led.color || 'white', null);
}
}
}
});
console.log('✅ LED mapping applied, total lights:', container.children.length);
} catch (error) {
console.error('Error applying LED mapping:', error);
}
} else {
console.log('📋 Using default LED layout');
// 如果没有映射配置,使用默认方式创建LED按钮(网格布局)
let ledIndex = 0;
let maxLedIndex = 0;
hidDevice.collections.forEach((collection) => {
if (!collection.outputReports || collection.outputReports.length === 0) {
return;
}
collection.outputReports.forEach((report) => {
report.items.forEach((item) => {
const reportCount = item.reportCount || 1;
for (let i = 0; i < reportCount; i++) {
const ledName = `${ledIndex}`;
// 创建网格布局:每行8个,间距12%
const col = ledIndex % 8;
const row = Math.floor(ledIndex / 8);
const x = 6 + col * 12; // 6, 18, 30, 42, 54, 66, 78, 90
const y = 6 + row * 12;
const button = createLEDButton(ledIndex, ledName, 'white', null, null, 'circle', true, 80, x, y);
container.appendChild(button);
updateLEDVisual(ledIndex, 'white', null);
maxLedIndex = ledIndex;
ledIndex++;
}
});
});
});
// 计算默认布局的最小高度
const calculateMinHeight = () => {
const totalRows = Math.floor(maxLedIndex / 8) + 1;
const basePercent = 10;
const actualSize = basePercent * 80 / 100; // size=80
const lastRowY = 6 + (totalRows - 1) * 12;
const maxBottom = lastRowY + actualSize;
const minHeight = (maxBottom + 6) * container.offsetWidth / 100;
container.style.minHeight = `${minHeight}px`;
};
setTimeout(calculateMinHeight, 0);
window.addEventListener('resize', calculateMinHeight);
}
if (container.children.length === 0) {
container.innerHTML = '<div style="text-align:center;color:#6c757d;"><span class="lang-en">No output LEDs found</span><span class="lang-zh">未找到输出 LED</span></div>';
}
}
function createLEDButton(index, name, color = 'white', channel = null, groupId = null, shape = 'rectangle', showIcon = true, size = 100, x = 0, y = 0, ratio = 1) {
const button = document.createElement('div');
// 如果是RGB类型且index不是数组,自动转换为数组[index, index+1, index+2]
if (color === 'rgb' && !Array.isArray(index)) {
index = [index, index + 1, index + 2];
}
const isRGB = (color === 'rgb' && Array.isArray(index));
button.className = `led-button shape-${shape || 'rectangle'}${isRGB ? ' rgb-type' : ''}`;
// RGB类型使用groupId作ID,其他使用index
const buttonId = isRGB ? `led-rgb-${groupId}` : `led-${index}${channel || ''}`;
button.id = buttonId;
button.style.position = 'absolute';
// 保存index(可能是数组或单个数字)
if (Array.isArray(index)) {
button.dataset.ledIndices = JSON.stringify(index);
} else {
button.dataset.ledIndex = index;
}
button.dataset.ledColor = color;
if (channel) button.dataset.channel = channel;
if (groupId) button.dataset.groupId = groupId;
if (shape) button.dataset.shape = shape;
// 应用size缩放(基于容器宽度)
// 基础大小为容器宽度的10%,size为缩放因子(100=原始大小)
const basePercent = 10; // 基础大小占容器宽度的百分比
const actualSize = basePercent * (size || 100) / 100;
// 根据形状设置宽高比
let widthSize = actualSize;
let heightSize = actualSize;
if (shape === 'rectangle') {
// 矩形:根据ratio设置高宽比,ratio为高度/宽度
// ratio默认为1(正方形),1.6表示高度是宽度的1.6倍
heightSize = actualSize * (ratio || 1);
}
// circle 保持 1:1
// 应用X、Y坐标定位(X和Y都是0-100的数值,都相对于容器宽度计算,保持1:1比例)
// 坐标代表元素的中心点
if (x !== undefined && x !== null) {
button.style.left = `calc(var(--container-width) * ${x} / 100 - var(--container-width) * ${widthSize} / 200)`;
}
if (y !== undefined && y !== null) {
// Y使用容器宽度作为基准,将数值转换为像素
button.style.top = `calc(var(--container-width) * ${y} / 100 - var(--container-width) * ${heightSize} / 200)`;
}
button.style.width = `calc(var(--container-width) * ${widthSize} / 100)`;
button.style.height = `calc(var(--container-width) * ${heightSize} / 100)`;
button.style.fontSize = `calc(var(--container-width) * ${actualSize} / 100 * 0.15)`; // 字体大小也按比例调整
const iconHtml = showIcon ? '<div class="led-icon">💡</div>' : '';
const rgbSlidersHtml = (color === 'rgb' && Array.isArray(index)) ? `
<div class="rgb-sliders">
<div class="rgb-sliders-row">
<div class="rgb-slider-container">
<div class="rgb-slider-title">R</div>
<div class="rgb-slider r" data-channel="0">
<div class="rgb-slider-fill" data-channel="0"></div>
<div class="rgb-slider-value" id="rgb-val-${groupId}-0">0</div>
</div>
</div>
<div class="rgb-slider-container">
<div class="rgb-slider-title">G</div>
<div class="rgb-slider g" data-channel="1">
<div class="rgb-slider-fill" data-channel="1"></div>
<div class="rgb-slider-value" id="rgb-val-${groupId}-1">0</div>
</div>
</div>
<div class="rgb-slider-container">
<div class="rgb-slider-title">B</div>
<div class="rgb-slider b" data-channel="2">
<div class="rgb-slider-fill" data-channel="2"></div>
<div class="rgb-slider-value" id="rgb-val-${groupId}-2">0</div>
</div>
</div>
</div>
</div>
` : '';
const indicatorId = (color === 'rgb' && Array.isArray(index)) ? `brightness-rgb-${groupId}` : `brightness-${index}${channel || ''}`;
// 当name为空时,不显示名称和亮度指示器
const nameHtml = name ? `<div class="led-name">${name}</div>` : '';
const indicatorHtml = (color === 'rgb' && Array.isArray(index)) ? '' : (name ? `<div class="brightness-indicator" id="${indicatorId}">0</div>` : '');
button.innerHTML = `
${nameHtml}
${iconHtml}
${rgbSlidersHtml}
${indicatorHtml}
`;
// 点击事件:非RGB类型切换亮灭
button.addEventListener('click', (e) => {
if (!button.classList.contains('dragging') && color !== 'rgb') {
toggleLED(index, color, channel);
}
});
// RGB类型:设置滑条交互
if (color === 'rgb' && Array.isArray(index)) {
const sliderContainers = button.querySelectorAll('.rgb-slider-container');
sliderContainers.forEach((container) => {
const slider = container.querySelector('.rgb-slider');
const channelIndex = parseInt(slider.dataset.channel);
const fill = slider.querySelector('.rgb-slider-fill');
const valueLabel = container.querySelector('.rgb-slider-value');
let isDragging = false;
const updateSlider = (e) => {
const rect = slider.getBoundingClientRect();
const y = e.clientY - rect.top;
const height = rect.height;
const percent = Math.max(0, Math.min(1, 1 - (y / height)));
const value = Math.round(percent * 255);
// 更新ledStates中的value属性
if (ledStates[index[channelIndex]]) {
ledStates[index[channelIndex]].value = value;
}
fill.style.height = `${percent * 100}%`;
// 更新数值显示
if (valueLabel) {
valueLabel.textContent = value;
}
updateLEDVisual(index, 'rgb', null);
sendOutputReport();
};
slider.addEventListener('mousedown', (e) => {
isDragging = true;
updateSlider(e);
e.stopPropagation();
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (isDragging) {
updateSlider(e);
e.preventDefault();
}
});
document.addEventListener('mouseup', () => {
isDragging = false;
});
// 初始化滑条高度和数值显示
const initialValue = (ledStates[index[channelIndex]] && ledStates[index[channelIndex]].value) || 0;
fill.style.height = `${(initialValue / 255) * 100}%`;
if (valueLabel) {
valueLabel.textContent = initialValue;
}
});
}
// 拖拽调整(仅限非RGB类型)
if (color !== 'rgb') {
let isDragging = false;
let lastY = 0;
button.addEventListener('mousedown', (e) => {
isDragging = true;
lastY = e.clientY;
button.classList.add('dragging');
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const deltaY = lastY - e.clientY;
const sensitivity = 2;
const currentBrightness = (ledStates[index] && ledStates[index].value) || 0;
let newBrightness = currentBrightness + Math.round(deltaY * sensitivity);
newBrightness = Math.max(0, Math.min(255, newBrightness));
setLEDBrightness(index, newBrightness, color, channel);
lastY = e.clientY;
e.preventDefault();
});
document.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
button.classList.remove('dragging');
}
});
}
updateLEDVisual(index, color, channel);
return button;
}
function setLEDBrightness(index, brightness, color = 'white', channel = null) {
if (ledStates[index]) {
ledStates[index].value = brightness;
}
updateLEDVisual(index, color, channel);
sendOutputReport();
}
// 设置RGB灯的颜色
function setRGBColor(groupId, r, g, b) {
// 查找该groupId对应的三个通道索引
const light = currentLedMapping.lights.find(led => led.name === groupId && led.color === 'rgb');
if (light && Array.isArray(light.index)) {
const rIndex = light.index[0];
const gIndex = light.index[1];
const bIndex = light.index[2];
if (ledStates[rIndex]) ledStates[rIndex].value = r;
if (ledStates[gIndex]) ledStates[gIndex].value = g;
if (ledStates[bIndex]) ledStates[bIndex].value = b;
// 更新视觉(使用RGB按钮ID)
updateLEDVisual(light.index, 'rgb', null);
sendOutputReport();
}
}
// 调整RGB灯的亮度(保持颜色比例)
function adjustRGBBrightness(groupId, delta) {
const light = currentLedMapping.lights.find(led => led.name === groupId && led.color === 'rgb');
if (!light || !Array.isArray(light.index)) return;
const rIndex = light.index[0];
const gIndex = light.index[1];
const bIndex = light.index[2];
let r = (ledStates[rIndex] && ledStates[rIndex].value) || 0;
let g = (ledStates[gIndex] && ledStates[gIndex].value) || 0;
let b = (ledStates[bIndex] && ledStates[bIndex].value) || 0;
// 计算当前最大值(用于保持颜色比例)
const maxValue = Math.max(r, g, b, 1);
// 计算新的最大值
let newMaxValue = maxValue + Math.round(delta);
newMaxValue = Math.max(0, Math.min(255, newMaxValue));
// 按比例调整所有通道
const ratio = newMaxValue / maxValue;
r = Math.round(r * ratio);
g = Math.round(g * ratio);
b = Math.round(b * ratio);
// 更新状态
ledStates[rIndex] = r;
ledStates[gIndex] = g;
ledStates[bIndex] = b;
// 更新视觉
updateLEDVisual(light.index, 'rgb', null);
sendOutputReport();
}
// 调整RGB灯的饱和度(灰度)
function adjustRGBSaturation(groupId, deltaX) {
const light = currentLedMapping.lights.find(led => led.name === groupId && led.color === 'rgb');
if (!light || !Array.isArray(light.index)) return;
const rIndex = light.index[0];
const gIndex = light.index[1];
const bIndex = light.index[2];
let r = (ledStates[rIndex] && ledStates[rIndex].value) || 0;
let g = (ledStates[gIndex] && ledStates[gIndex].value) || 0;
let b = (ledStates[bIndex] && ledStates[bIndex].value) || 0;
// 计算当前的平均值(灰度)
const avg = (r + g + b) / 3;
// 调整饱和度:向右拖拽增加饱和度(离灰度更远),向左拖拽降低饱和度(趋向灰度)
const sensitivity = 0.02; // 饱和度调整灵敏度
const saturationChange = deltaX * sensitivity;
// 计算新的RGB值:在当前值和灰度之间插值
// saturationChange > 0: 增加饱和度(远离灰度)
// saturationChange < 0: 降低饱和度(趋向灰度)
r = r + (r - avg) * saturationChange;
g = g + (g - avg) * saturationChange;
b = b + (b - avg) * saturationChange;
// 限制范围
r = Math.max(0, Math.min(255, Math.round(r)));
g = Math.max(0, Math.min(255, Math.round(g)));
b = Math.max(0, Math.min(255, Math.round(b)));
// 更新状态
if (ledStates[rIndex]) ledStates[rIndex].value = r;
if (ledStates[gIndex]) ledStates[gIndex].value = g;
if (ledStates[bIndex]) ledStates[bIndex].value = b;
// 更新视觉(使用RGB按钮ID)
const rgbLight = currentLedMapping.lights.find(led => led.name === groupId && led.color === 'rgb');
if (rgbLight) {
updateLEDVisual(rgbLight.index, 'rgb', null);
}
sendOutputReport();
}
function updateLEDVisual(index, color = 'white', channel = null) {
// RGB类型使用数组索引,其他使用单个索引
let button;
if (color === 'rgb' && Array.isArray(index)) {
// 通过index数组查找对应的RGB按钮
const buttons = document.querySelectorAll('.led-button.rgb-type');
for (const btn of buttons) {
const indices = btn.dataset.ledIndices;
if (indices && JSON.stringify(JSON.parse(indices)) === JSON.stringify(index)) {
button = btn;
break;
}
}
} else {
const buttonId = `led-${index}${channel || ''}`;
button = document.getElementById(buttonId);
}
if (!button) return;
// RGB类型显示混合后的亮度
let brightness;
if (color === 'rgb' && Array.isArray(index)) {
const r = (ledStates[index[0]] && ledStates[index[0]].value) || 0;
const g = (ledStates[index[1]] && ledStates[index[1]].value) || 0;
const b = (ledStates[index[2]] && ledStates[index[2]].value) || 0;
brightness = Math.max(r, g, b); // 使用最大值作为显示亮度
// 更新亮度指示器
const indicator = button.querySelector('.brightness-indicator');
if (indicator) {
indicator.textContent = brightness;
}
} else {
brightness = (ledStates[index] && ledStates[index].value) || 0;
// 更新亮度显示
const indicator = document.getElementById(`brightness-${index}${channel || ''}`);
if (indicator) {
indicator.textContent = brightness;
}
}
// 定义颜色映射(在两种状态都需要使用)
const colorMap = {
'red': { hue: 0, rgb: '255, 0, 0' },
'green': { hue: 120, rgb: '0, 255, 0' },
'blue': { hue: 240, rgb: '0, 0, 255' },
'yellow': { hue: 60, rgb: '255, 255, 0' },
'cyan': { hue: 180, rgb: '0, 255, 255' },
'magenta': { hue: 300, rgb: '255, 0, 255' },
'pink': { hue: 330, rgb: '255, 192, 203' },
'white': { hue: 0, rgb: '255, 255, 255' },
'orange': { hue: 30, rgb: '255, 165, 0' }
};
// 计算暗色外框和主体颜色
let darkBorderColor, hue, saturation;
if (color === 'rgb' && Array.isArray(index)) {
// RGB类型:根据实际RGB值计算色相
const r = (ledStates[index[0]] && ledStates[index[0]].value) || 0;
const g = (ledStates[index[1]] && ledStates[index[1]].value) || 0;
const b = (ledStates[index[2]] && ledStates[index[2]].value) || 0;
// 计算色相(简化版)
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const delta = max - min;
if (delta === 0 || max === 0) {
hue = 0; // 灰色
saturation = 0; // 无饱和度,显示为灰色
} else {
saturation = 20; // 正常饱和度
if (max === r) {
hue = 60 * (((g - b) / delta) % 6);
} else if (max === g) {
hue = 60 * (((b - r) / delta) + 2);
} else {
hue = 60 * (((r - g) / delta) + 4);
}
if (hue < 0) hue += 360;
}
darkBorderColor = max === 0 ? `hsl(0, 0%, 25%)` : `hsl(${hue}, 100%, 25%)`;
} else {
const colorInfo = colorMap[color] || colorMap['white'];
hue = colorInfo.hue;
saturation = 20;
if (color === 'white') {
darkBorderColor = `hsl(0, 0%, 35%)`;
} else {
darkBorderColor = `hsl(${hue}, 100%, 22%)`;
}
}
// 主体固定为85%亮度、根据颜色调整饱和度
button.style.background = `linear-gradient(145deg, hsl(${hue}, ${saturation}%, 85%), hsl(${hue}, ${saturation}%, 80%))`;
if (brightness === 0) {
// 关闭状态 - 只显示主体,无光晕
button.classList.remove('on');
button.style.borderColor = darkBorderColor;
button.style.boxShadow = '';
button.style.color = '#444';
} else {
// 亮起状态:主体不变,只通过光晕显示点亮效果
button.classList.add('on');
// 使用 RGB 模式计算光晕:
// 0-160: 映射到颜色的 0-255
// 160-255: 保持主颜色 255,逐步提高其他通道到 128(发白但不完全白)
let glowR, glowG, glowB;
const glowAlpha = 0.2 + (brightness / 255) * 0.6;
// 获取基础颜色的 RGB
let baseR, baseG, baseB;
if (color === 'rgb' && Array.isArray(index)) {
// RGB类型:使用实际的RGB值
baseR = ((ledStates[index[0]] && ledStates[index[0]].value) || 0) / 255;
baseG = ((ledStates[index[1]] && ledStates[index[1]].value) || 0) / 255;
baseB = ((ledStates[index[2]] && ledStates[index[2]].value) || 0) / 255;
// 对于RGB类型,brightness已经是最大值
} else {
const colorInfo = colorMap[color] || colorMap['white'];
const rgb = colorInfo.rgb.split(',').map(v => parseInt(v.trim()));
baseR = rgb[0] / 255;
baseG = rgb[1] / 255;
baseB = rgb[2] / 255;
}
if (brightness <= 160) {
// 0-160: 线性映射到颜色的 0-255
const intensity = (brightness / 160) * 255;
glowR = Math.round(baseR * intensity);
glowG = Math.round(baseG * intensity);
glowB = Math.round(baseB * intensity);
} else {
// 160-255: 主颜色保持 255,其他通道提升到 128
const ratio = (brightness - 160) / 95;
const otherChannelValue = Math.round(ratio * 128);
glowR = baseR === 1 ? 255 : Math.round(baseR * 255 + (1 - baseR) * otherChannelValue);
glowG = baseG === 1 ? 255 : Math.round(baseG * 255 + (1 - baseG) * otherChannelValue);
glowB = baseB === 1 ? 255 : Math.round(baseB * 255 + (1 - baseB) * otherChannelValue);
}
const glowColor = `rgba(${glowR}, ${glowG}, ${glowB}, ${glowAlpha})`;
let borderColor;
if (color === 'rgb' && channel) {
switch (channel) {
case 'R':
borderColor = `hsl(0, 100%, 30%)`;
break;
case 'G':
borderColor = `hsl(120, 100%, 25%)`;
break;
case 'B':
borderColor = `hsl(240, 100%, 30%)`;
break;
}
} else {
if (color === 'white') {
borderColor = `hsl(0, 0%, 40%)`;
} else {
borderColor = `hsl(${hue}, 100%, 25%)`;
}
}
button.style.borderColor = borderColor;
// 增强的光晕效果:内部光晕+外部扩散+底部阴影
// 蓝色系需要额外增强(人眼对蓝色感知较暗)
const isBlue = (color === 'blue' || color === 'cyan' || (color === 'rgb' && channel === 'B'));
const glowBoost = isBlue ? 1.4 : 1.0;
const glowIntensity = (0.3 + (brightness / 255) * 0.7) * glowBoost;
const glowSize = (15 + (brightness / 255) * 25) * glowBoost;
button.style.boxShadow = `
0 0 ${glowSize * 0.4}px ${glowColor},
0 0 ${glowSize}px ${glowColor.replace(/[\d.]+\)$/, `${glowIntensity * 0.5})`)},
0 0 ${glowSize * 1.5}px ${glowColor.replace(/[\d.]+\)$/, `${glowIntensity * 0.3})`)},
0 4px 12px rgba(0,0,0,0.3)`;
// 文字颜色
button.style.color = '#444';
}
}
async function toggleLED(index, color = 'white', channel = null) {
if (!hidDevice) {
const isZh = document.body.classList.contains('zh');
alert(isZh ? '未连接设备' : 'No HID device connected');
return;
}
// 切换状态:0 -> 255, 其他 -> 0
if (ledStates[index]) {
ledStates[index].value = (ledStates[index].value === 0) ? 255 : 0;
}
// 更新视觉效果
updateLEDVisual(index, color, channel);
// 发送报告
await sendOutputReport();
}
async function sendOutputReport() {
try {
// 按report ID分组更新数据
const reportsToSend = new Map();
Object.keys(ledStates).forEach(index => {
const ledState = ledStates[index];
const reportId = ledState.reportId;
const offset = ledState.offset;
const value = ledState.value;
if (!outputReports.has(reportId)) {
return;
}
const reportInfo = outputReports.get(reportId);
if (offset < reportInfo.size) {
reportInfo.data[offset] = value;
reportsToSend.set(reportId, reportInfo);
}
});
// 发送所有需要更新的report
for (const [reportId, reportInfo] of reportsToSend) {
console.log(`Sending report ID ${reportId}, size ${reportInfo.size} bytes:`, Array.from(reportInfo.data));
await hidDevice.sendReport(reportId, reportInfo.data);
}
} catch (error) {
console.error('Failed to send output report:', error);
const isZh = document.body.classList.contains('zh');
alert(isZh ? `发送失败: ${error.message}` : `Failed to send LED data: ${error.message}`);
}
}
// 添加事件监听器
window.addEventListener('gamepadconnected', (e) => {
console.log('手柄已连接:', e.gamepad);
updateStatus();
renderGamepads(); // 添加这行
});
window.addEventListener('gamepaddisconnected', (e) => {
console.log('手柄已断开:', e.gamepad);
delete gamepads[e.gamepad.index];
delete buttonStates[e.gamepad.index];
delete buttonCounts[e.gamepad.index];
delete stickTrails[e.gamepad.index];
clearGamepadAxisStates(e.gamepad.index);
updateStatus();
renderGamepads();
});
// 更新状态显示
function updateStatus() {
const gamepadList = navigator.getGamepads();
const connectedGamepads = Array.from(gamepadList).filter(gp => gp !== null);
const statusElement = document.getElementById('status');
if (connectedGamepads.length > 0) {
statusElement.innerHTML = `<span class="lang-en">Connected ${connectedGamepads.length} gamepad(s)</span><span class="lang-zh">已连接 ${connectedGamepads.length} 个手柄</span>`;
statusElement.className = 'status connected';
} else {
statusElement.innerHTML = '<span class="lang-en">Please connect a gamepad and press any button to activate</span><span class="lang-zh">请连接游戏手柄并按任意按钮激活</span>';
statusElement.className = 'status disconnected';
}
}
// 获取按钮信息(名称和类型)
function getButtonInfo(index) {
return { name: `${index}`, type: 'default' };
}
// 获取轴名称
function getAxisName(index) {
const axisNames = [
'<span class="lang-en">Left X</span><span class="lang-zh">左 X</span>',
'<span class="lang-en">Left Y</span><span class="lang-zh">左 Y</span>',
'<span class="lang-en">Right X</span><span class="lang-zh">右 X</span>',
'<span class="lang-en">Right Y</span><span class="lang-zh">右 Y</span>'
];
return axisNames[index] || `<span class="lang-en">Axis ${index}</span><span class="lang-zh">轴 ${index}</span>`;
}
const HAT_DIRECTIONS = [
{ en: 'Up', zh: '上', symbol: '↑', x: 50, y: 18 },
{ en: 'Up-Right', zh: '右上', symbol: '↗', x: 72, y: 28 },
{ en: 'Right', zh: '右', symbol: '→', x: 82, y: 50 },
{ en: 'Down-Right', zh: '右下', symbol: '↘', x: 72, y: 72 },
{ en: 'Down', zh: '下', symbol: '↓', x: 50, y: 82 },
{ en: 'Down-Left', zh: '左下', symbol: '↙', x: 28, y: 72 },
{ en: 'Left', zh: '左', symbol: '←', x: 18, y: 50 },
{ en: 'Up-Left', zh: '左上', symbol: '↖', x: 28, y: 28 }
];
const HAT_CALIBRATION = {
center: 1.286,
up: -1,
left: 0.714,
down: 0.143,
right: -0.429
};
const HAT_SEGMENTS = {
upRight: (HAT_CALIBRATION.up + HAT_CALIBRATION.right) / 2,
rightDown: (HAT_CALIBRATION.right + HAT_CALIBRATION.down) / 2,
downLeft: (HAT_CALIBRATION.down + HAT_CALIBRATION.left) / 2,
leftCenter: (HAT_CALIBRATION.left + HAT_CALIBRATION.center) / 2
};
function isAxisOutOfNormalRange(axisValue) {
return Number.isFinite(axisValue) && (axisValue < -1 || axisValue > 1);
}
function clearGamepadAxisStates(gamepadIndex) {
const prefix = `${gamepadIndex}-`;
Object.keys(axisDisplayModes).forEach((key) => {
if (key.startsWith(prefix)) {
delete axisDisplayModes[key];
}
});
Object.keys(hatAxisLocks).forEach((key) => {
if (key.startsWith(prefix)) {
delete hatAxisLocks[key];
}
});
}
function getAxisDisplayMode(gamepadIndex, axisIndex, axisValue) {
const key = `${gamepadIndex}-${axisIndex}`;
if (hatAxisLocks[key]) {
return 'hat';
}
if (isAxisOutOfNormalRange(axisValue)) {
hatAxisLocks[key] = true;
return 'hat';
}
return 'axis';
}
function decodeHatNibble(axisValue) {
if (!Number.isFinite(axisValue)) {
return 8;
}
if (axisValue >= HAT_SEGMENTS.leftCenter) {
return 8;
}
if (axisValue >= HAT_SEGMENTS.downLeft) {
return 6;
}
if (axisValue >= HAT_SEGMENTS.rightDown) {
return 4;
}
if (axisValue >= HAT_SEGMENTS.upRight) {
return 2;
}
return 0;
}
function getHatState(gamepadIndex, axisIndex, axisValue) {
const nibble = decodeHatNibble(axisValue);
return {
nibble,
directionIndex: nibble <= 7 ? nibble : -1
};
}
function getHatStateHTML(gamepadIndex, axisIndex, axisValue) {
const { nibble, directionIndex } = getHatState(gamepadIndex, axisIndex, axisValue);
const raw = Number.isFinite(axisValue) ? axisValue.toFixed(3) : String(axisValue);
const state = directionIndex === -1 ? 'C' : 'D';
return `RAW=${raw} NIBBLE=${nibble} ${state}`;
}
function renderLinearAxisItem(gamepadIndex, axisIndex, axisValue) {
return `
<div class="axis-item">
<div class="axis-name">${getAxisName(axisIndex)}</div>
<div class="axis-bar">
<div class="axis-center-line"></div>
<div class="axis-indicator" id="axis-bar-${gamepadIndex}-${axisIndex}"></div>
</div>
<div class="axis-value" id="axis-${gamepadIndex}-${axisIndex}">${axisValue.toFixed(3)}</div>
</div>
`;
}
function renderHatAxisItem(gamepadIndex, axisIndex, axisValue) {
const hatDirs = HAT_DIRECTIONS.map((dir, dirIndex) => `
<div class="hat-dir" id="hat-dir-${gamepadIndex}-${axisIndex}-${dirIndex}" style="left:${dir.x}%;top:${dir.y}%">${dir.symbol}</div>
`).join('');
return `
<div class="axis-item hat-item">
<div class="hat-name">HAT ${axisIndex}</div>
<div class="hat-pad">
${hatDirs}
<div class="hat-center" id="hat-center-${gamepadIndex}-${axisIndex}"></div>
</div>
<div class="hat-value" id="hat-value-${gamepadIndex}-${axisIndex}">${getHatStateHTML(gamepadIndex, axisIndex, axisValue)}</div>
</div>
`;
}
function renderAxisItem(gamepadIndex, axisIndex, axisValue) {
const mode = getAxisDisplayMode(gamepadIndex, axisIndex, axisValue);
axisDisplayModes[`${gamepadIndex}-${axisIndex}`] = mode;
if (mode === 'hat') {
return renderHatAxisItem(gamepadIndex, axisIndex, axisValue);
}
return renderLinearAxisItem(gamepadIndex, axisIndex, axisValue);
}
function updateLinearAxisDisplay(gamepadIndex, axisIndex, axisValue) {
const clampedAxis = Math.max(-1, Math.min(1, axisValue));
const axisElement = document.getElementById(`axis-${gamepadIndex}-${axisIndex}`);
if (axisElement) {
axisElement.textContent = axisValue.toFixed(3);
axisElement.style.color = '';
axisElement.style.fontWeight = '';
}
const axisBarElement = document.getElementById(`axis-bar-${gamepadIndex}-${axisIndex}`);
const axisBarContainer = axisBarElement ? axisBarElement.parentElement : null;
if (axisBarElement) {
const position = ((clampedAxis + 1) / 2) * 94;
axisBarElement.style.top = `${position}%`;
axisBarElement.style.background = '#007bff';
updateAxisTrail(gamepadIndex, axisIndex, position, axisBarContainer);
}
}
function updateHatAxisDisplay(gamepadIndex, axisIndex, axisValue) {
const { directionIndex } = getHatState(gamepadIndex, axisIndex, axisValue);
const valueElement = document.getElementById(`hat-value-${gamepadIndex}-${axisIndex}`);
if (valueElement) {
valueElement.innerHTML = getHatStateHTML(gamepadIndex, axisIndex, axisValue);
}
for (let dir = 0; dir < HAT_DIRECTIONS.length; dir++) {
const dirElement = document.getElementById(`hat-dir-${gamepadIndex}-${axisIndex}-${dir}`);
if (dirElement) {
dirElement.classList.toggle('active', dir === directionIndex);
}
}
const centerElement = document.getElementById(`hat-center-${gamepadIndex}-${axisIndex}`);
if (centerElement) {
centerElement.classList.toggle('active', directionIndex === -1);
}
}
// 更新摇杆拖尾效果
function updateStickTrail(gamepadIndex, stickName, posX, posY, canvasElement) {
const key = `${gamepadIndex}-${stickName}`;
const maxTrails = 15; // 最大拖尾点数量
// 初始化拖尾数组
if (!stickTrails[key]) {
stickTrails[key] = [];
}
// 添加新位置
stickTrails[key].push({ x: posX, y: posY, time: Date.now() });
// 限制拖尾数量
if (stickTrails[key].length > maxTrails) {
stickTrails[key].shift();
}
// 清除旧的拖尾元素
if (canvasElement) {
const oldTrails = canvasElement.querySelectorAll('.stick-2d-trail');
oldTrails.forEach(trail => trail.remove());
// 创建新的拖尾元素
stickTrails[key].forEach((pos, index) => {
const trail = document.createElement('div');
trail.className = 'stick-2d-trail';
trail.style.left = `${pos.x}%`;
trail.style.top = `${pos.y}%`;
// 计算透明度(越旧越透明)
const opacity = (index + 1) / stickTrails[key].length * 0.5;
trail.style.opacity = opacity;
// 计算大小(越旧越小)
const size = 4 + (index + 1) / stickTrails[key].length * 6;
trail.style.width = `${size}px`;
trail.style.height = `${size}px`;
canvasElement.appendChild(trail);
});
}
}
// 更新单轴拖尾效果
function updateAxisTrail(gamepadIndex, axisIndex, position, containerElement) {
const key = `axis-${gamepadIndex}-${axisIndex}`;
const maxTrails = 15; // 最大拖尾点数量
// 初始化拖尾数组
if (!stickTrails[key]) {
stickTrails[key] = [];
}
// 添加新位置
stickTrails[key].push({ position: position, time: Date.now() });
// 限制拖尾数量
if (stickTrails[key].length > maxTrails) {
stickTrails[key].shift();
}
// 清除旧的拖尾元素
if (containerElement) {
const oldTrails = containerElement.querySelectorAll('.axis-trail');
oldTrails.forEach(trail => trail.remove());
// 创建新的拖尾元素
stickTrails[key].forEach((pos, index) => {
const trail = document.createElement('div');
trail.className = 'axis-trail';
trail.style.top = `${pos.position}%`;
// 计算透明度(越旧越透明)
const opacity = (index + 1) / stickTrails[key].length * 0.4;
trail.style.opacity = opacity;
// 计算高度(越旧越小)
const height = 2 + (index + 1) / stickTrails[key].length * 2;
trail.style.height = `${height}px`;
containerElement.appendChild(trail);
});
}
}
// 检测按钮状态变化
function checkButtonPress(gamepadIndex, buttonIndex, isPressed) {
const key = `${gamepadIndex}-${buttonIndex}`;
// 初始化状态
if (!(key in buttonStates)) {
buttonStates[key] = false;
buttonCounts[key] = 0;
}
// 检测按下边沿(从未按下变为按下)
if (isPressed && !buttonStates[key]) {
buttonStates[key] = true;
buttonCounts[key]++;
} else if (!isPressed && buttonStates[key]) {
buttonStates[key] = false;
}
// 实时更新视觉状态(按下亮起,松开熄灭)
const buttonElement = document.getElementById(`button-${gamepadIndex}-${buttonIndex}`);
if (buttonElement) {
if (isPressed) {
buttonElement.classList.add('pressed');
} else {
buttonElement.classList.remove('pressed');
}
}
}
// 渲染手柄界面
function renderGamepads() {
const container = document.getElementById('gamepads');
container.innerHTML = '';
const gamepadList = navigator.getGamepads();
for (let i = 0; i < gamepadList.length; i++) {
const gamepad = gamepadList[i];
if (!gamepad) continue;
const gamepadDiv = document.createElement('div');
gamepadDiv.className = 'gamepad';
// 按按钮类型分组
const buttonGroups = {
default: { title: '<span class="lang-en">Buttons</span><span class="lang-zh">按钮</span>', buttons: [] }
};
gamepad.buttons.forEach((button, buttonIndex) => {
const buttonInfo = getButtonInfo(buttonIndex);
const key = `${gamepad.index}-${buttonIndex}`;
// 确保初始化计数
if (!(key in buttonCounts)) {
buttonCounts[key] = 0;
}
const count = buttonCounts[key];
buttonGroups.default.buttons.push({
index: buttonIndex,
name: buttonInfo.name,
type: buttonInfo.type,
count: count
});
});
let buttonsHTML = '';
for (const [groupType, group] of Object.entries(buttonGroups)) {
if (group.buttons.length > 0) {
buttonsHTML += `
<div class="button-group">
<div class="button-group-title">${group.title}</div>
<div class="buttons-container">
${group.buttons.map(btn => `
<div class="button-circle ${btn.type}" id="button-${gamepad.index}-${btn.index}">
<div class="button-name">${btn.name}</div>
<div class="button-count">${btn.count}</div>
</div>
`).join('')}
</div>
</div>
`;
}
}
gamepadDiv.innerHTML = `
<div class="gamepad-title">
🎮 ${gamepad.id} <span class="lang-en">Index</span><span class="lang-zh">索引</span>: ${gamepad.index}
<button class="reset-btn" onclick="resetGamepad(${gamepad.index})"
style="float: right; padding: 6px 12px; font-size: 14px; background-color: #6c757d;">
🔄 <span class="lang-en">Reset Counts</span><span class="lang-zh">复位计数</span>
</button>
<div style="clear: both;"></div>
</div>
${buttonsHTML}
<div class="axes-section">
<h4><span class="lang-en">Axes</span><span class="lang-zh">摇杆轴</span></h4>
<div class="axes-grid">
${gamepad.axes.length >= 2 ? `
<div class="stick-2d">
<div class="axis-name"><span class="lang-en">Left Stick</span><span class="lang-zh">左摇杆</span></div>
<div class="stick-2d-canvas" id="stick-left-${gamepad.index}">
<div class="stick-2d-cross-h"></div>
<div class="stick-2d-cross-v"></div>
<div class="stick-2d-center"></div>
<div class="stick-2d-indicator" id="stick-left-indicator-${gamepad.index}"></div>
</div>
<div class="stick-2d-value" id="stick-left-value-${gamepad.index}">X: 0.000, Y: 0.000</div>
</div>
` : ''}
${gamepad.axes.length >= 4 ? `
<div class="stick-2d">
<div class="axis-name"><span class="lang-en">Right Stick</span><span class="lang-zh">右摇杆</span></div>
<div class="stick-2d-canvas" id="stick-right-${gamepad.index}">
<div class="stick-2d-cross-h"></div>
<div class="stick-2d-cross-v"></div>
<div class="stick-2d-center"></div>
<div class="stick-2d-indicator" id="stick-right-indicator-${gamepad.index}"></div>
</div>
<div class="stick-2d-value" id="stick-right-value-${gamepad.index}">X: 0.000, Y: 0.000</div>
</div>
` : ''}
${gamepad.axes.map((axis, axisIndex) => renderAxisItem(gamepad.index, axisIndex, axis)).join('')}
</div>
</div>
`;
container.appendChild(gamepadDiv);
}
}
// 主循环 - 持续检测手柄状态
function gameLoop() {
const gamepadList = navigator.getGamepads();
let needsRender = false;
for (let i = 0; i < gamepadList.length; i++) {
const gamepad = gamepadList[i];
if (!gamepad) continue;
// 检查是否有新手柄
if (!gamepads[gamepad.index]) {
gamepads[gamepad.index] = true;
needsRender = true;
}
// 检测按钮状态
gamepad.buttons.forEach((button, buttonIndex) => {
const isPressed = button.pressed || button.value > 0.5;
checkButtonPress(gamepad.index, buttonIndex, isPressed);
});
// 更新轴显示
gamepad.axes.forEach((axis, axisIndex) => {
const axisModeKey = `${gamepad.index}-${axisIndex}`;
const expectedMode = getAxisDisplayMode(gamepad.index, axisIndex, axis);
if (axisDisplayModes[axisModeKey] !== expectedMode) {
axisDisplayModes[axisModeKey] = expectedMode;
needsRender = true;
return;
}
if (expectedMode === 'hat') {
updateHatAxisDisplay(gamepad.index, axisIndex, axis);
} else {
updateLinearAxisDisplay(gamepad.index, axisIndex, axis);
}
});
// 更新2D摇杆显示
if (gamepad.axes.length >= 2) {
const leftX = Math.max(-1, Math.min(1, gamepad.axes[0]));
const leftY = Math.max(-1, Math.min(1, gamepad.axes[1]));
const leftIndicator = document.getElementById(`stick-left-indicator-${gamepad.index}`);
const leftValue = document.getElementById(`stick-left-value-${gamepad.index}`);
const leftCanvas = document.getElementById(`stick-left-${gamepad.index}`);
if (leftIndicator) {
const posX = ((leftX + 1) / 2) * 100;
const posY = ((leftY + 1) / 2) * 100;
leftIndicator.style.left = `${posX}%`;
leftIndicator.style.top = `${posY}%`;
// 添加拖尾效果
updateStickTrail(gamepad.index, 'left', posX, posY, leftCanvas);
}
if (leftValue) {
leftValue.textContent = `X: ${gamepad.axes[0].toFixed(3)}, Y: ${gamepad.axes[1].toFixed(3)}`;
}
}
if (gamepad.axes.length >= 4) {
const rightX = Math.max(-1, Math.min(1, gamepad.axes[2]));
const rightY = Math.max(-1, Math.min(1, gamepad.axes[3]));
const rightIndicator = document.getElementById(`stick-right-indicator-${gamepad.index}`);
const rightValue = document.getElementById(`stick-right-value-${gamepad.index}`);
const rightCanvas = document.getElementById(`stick-right-${gamepad.index}`);
if (rightIndicator) {
const posX = ((rightX + 1) / 2) * 100;
const posY = ((rightY + 1) / 2) * 100;
rightIndicator.style.left = `${posX}%`;
rightIndicator.style.top = `${posY}%`;
// 添加拖尾效果
updateStickTrail(gamepad.index, 'right', posX, posY, rightCanvas);
}
if (rightValue) {
rightValue.textContent = `X: ${gamepad.axes[2].toFixed(3)}, Y: ${gamepad.axes[3].toFixed(3)}`;
}
}
// 更新按钮计数显示
gamepad.buttons.forEach((button, buttonIndex) => {
const key = `${gamepad.index}-${buttonIndex}`;
const count = buttonCounts[key] || 0;
const countElement = document.querySelector(`#button-${gamepad.index}-${buttonIndex} .button-count`);
if (countElement) {
countElement.textContent = count;
}
});
}
if (needsRender) {
renderGamepads();
}
updateStatus();
requestAnimationFrame(gameLoop);
}
// 复位所有计数
function resetAllCounts() {
buttonCounts = {};
buttonStates = {};
renderGamepads();
}
// 复位当前手柄计数
function resetCurrentGamepad() {
const gamepadList = navigator.getGamepads();
const connectedGamepads = Array.from(gamepadList).filter(gp => gp !== null);
if (connectedGamepads.length === 0) {
const isZh = document.body.classList.contains('zh');
alert(isZh ? '没有连接的手柄' : 'No gamepad connected');
return;
}
if (connectedGamepads.length === 1) {
const gamepad = connectedGamepads[0];
// 复位这个手柄的所有按钮计数
for (let i = 0; i < gamepad.buttons.length; i++) {
const key = `${gamepad.index}-${i}`;
buttonCounts[key] = 0;
buttonStates[key] = false;
}
renderGamepads();
} else {
// 多个手柄,让用户选择
const isZh = document.body.classList.contains('zh');
let gamepadIndex = prompt(isZh
? `检测到 ${connectedGamepads.length} 个手柄,请输入要复位的手柄索引 (0-${connectedGamepads.length-1}):`
: `Detected ${connectedGamepads.length} gamepads, enter gamepad index to reset (0-${connectedGamepads.length-1}):`);
gamepadIndex = parseInt(gamepadIndex);
if (isNaN(gamepadIndex) || gamepadIndex < 0 || gamepadIndex >= gamepadList.length) {
const isZh = document.body.classList.contains('zh');
alert(isZh ? '无效的手柄索引' : 'Invalid gamepad index');
return;
}
const gamepad = gamepadList[gamepadIndex];
if (!gamepad) {
const isZh = document.body.classList.contains('zh');
alert(isZh ? '该索引的手柄未连接' : 'Gamepad at this index is not connected');
return;
}
for (let i = 0; i < gamepad.buttons.length; i++) {
const key = `${gamepadIndex}-${i}`;
buttonCounts[key] = 0;
buttonStates[key] = false;
}
renderGamepads();
}
}
// 复位指定手柄
function resetGamepad(gamepadIndex) {
const gamepadList = navigator.getGamepads();
const gamepad = gamepadList[gamepadIndex];
if (!gamepad) {
const isZh = document.body.classList.contains('zh');
alert(isZh ? '手柄未找到' : 'Gamepad not found');
return;
}
for (let i = 0; i < gamepad.buttons.length; i++) {
const key = `${gamepadIndex}-${i}`;
buttonCounts[key] = 0;
buttonStates[key] = false;
}
renderGamepads();
}
// 启动主循环
updateStatus();
renderGamepads();
gameLoop();
</script>
</body>
</html>