mirror of
https://github.com/whowechina/iidx_pico.git
synced 2026-10-10 00:08:13 +03:00
2260 lines
96 KiB
HTML
2260 lines
96 KiB
HTML
<!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> |