@jjlmoya/utils-hardware 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/package.json +60 -0
  2. package/src/category/i18n/en.ts +107 -0
  3. package/src/category/i18n/es.ts +107 -0
  4. package/src/category/i18n/fr.ts +107 -0
  5. package/src/category/index.ts +18 -0
  6. package/src/category/seo.astro +15 -0
  7. package/src/components/PreviewNavSidebar.astro +116 -0
  8. package/src/components/PreviewToolbar.astro +143 -0
  9. package/src/data.ts +11 -0
  10. package/src/env.d.ts +5 -0
  11. package/src/index.ts +26 -0
  12. package/src/layouts/PreviewLayout.astro +117 -0
  13. package/src/pages/[locale]/[slug].astro +146 -0
  14. package/src/pages/[locale].astro +251 -0
  15. package/src/pages/index.astro +4 -0
  16. package/src/tests/faq_count.test.ts +19 -0
  17. package/src/tests/locale_completeness.test.ts +42 -0
  18. package/src/tests/mocks/astro_mock.js +2 -0
  19. package/src/tests/no_h1_in_components.test.ts +48 -0
  20. package/src/tests/schemas_fulfillment.test.ts +23 -0
  21. package/src/tests/seo_length.test.ts +23 -0
  22. package/src/tests/title_quality.test.ts +55 -0
  23. package/src/tests/tool_validation.test.ts +17 -0
  24. package/src/tool/batteryHealthEstimator/bibliography.astro +14 -0
  25. package/src/tool/batteryHealthEstimator/component.astro +606 -0
  26. package/src/tool/batteryHealthEstimator/i18n/en.ts +158 -0
  27. package/src/tool/batteryHealthEstimator/i18n/es.ts +158 -0
  28. package/src/tool/batteryHealthEstimator/i18n/fr.ts +158 -0
  29. package/src/tool/batteryHealthEstimator/index.ts +34 -0
  30. package/src/tool/batteryHealthEstimator/seo.astro +14 -0
  31. package/src/tool/batteryHealthEstimator/ui.ts +31 -0
  32. package/src/tool/deadPixelTest/bibliography.astro +31 -0
  33. package/src/tool/deadPixelTest/component.astro +717 -0
  34. package/src/tool/deadPixelTest/i18n/en.ts +218 -0
  35. package/src/tool/deadPixelTest/i18n/es.ts +218 -0
  36. package/src/tool/deadPixelTest/i18n/fr.ts +218 -0
  37. package/src/tool/deadPixelTest/index.ts +34 -0
  38. package/src/tool/deadPixelTest/seo.astro +14 -0
  39. package/src/tool/deadPixelTest/ui.ts +20 -0
  40. package/src/tool/gamepadTest/bibliography.astro +30 -0
  41. package/src/tool/gamepadTest/component.astro +1419 -0
  42. package/src/tool/gamepadTest/i18n/en.ts +186 -0
  43. package/src/tool/gamepadTest/i18n/es.ts +186 -0
  44. package/src/tool/gamepadTest/i18n/fr.ts +186 -0
  45. package/src/tool/gamepadTest/index.ts +34 -0
  46. package/src/tool/gamepadTest/seo.astro +14 -0
  47. package/src/tool/gamepadTest/ui.ts +57 -0
  48. package/src/tool/gamepadVibrationTester/bibliography.astro +14 -0
  49. package/src/tool/gamepadVibrationTester/component.astro +694 -0
  50. package/src/tool/gamepadVibrationTester/i18n/en.ts +155 -0
  51. package/src/tool/gamepadVibrationTester/i18n/es.ts +155 -0
  52. package/src/tool/gamepadVibrationTester/i18n/fr.ts +150 -0
  53. package/src/tool/gamepadVibrationTester/index.ts +34 -0
  54. package/src/tool/gamepadVibrationTester/seo.astro +14 -0
  55. package/src/tool/gamepadVibrationTester/ui.ts +28 -0
  56. package/src/tool/keyboardTest/bibliography.astro +30 -0
  57. package/src/tool/keyboardTest/component.astro +601 -0
  58. package/src/tool/keyboardTest/i18n/en.ts +146 -0
  59. package/src/tool/keyboardTest/i18n/es.ts +146 -0
  60. package/src/tool/keyboardTest/i18n/fr.ts +146 -0
  61. package/src/tool/keyboardTest/index.ts +34 -0
  62. package/src/tool/keyboardTest/seo.astro +14 -0
  63. package/src/tool/keyboardTest/ui.ts +8 -0
  64. package/src/tool/mousePollingTest/bibliography.astro +14 -0
  65. package/src/tool/mousePollingTest/component.astro +237 -0
  66. package/src/tool/mousePollingTest/i18n/en.ts +135 -0
  67. package/src/tool/mousePollingTest/i18n/es.ts +135 -0
  68. package/src/tool/mousePollingTest/i18n/fr.ts +135 -0
  69. package/src/tool/mousePollingTest/index.ts +34 -0
  70. package/src/tool/mousePollingTest/logic/MovingAverage.ts +20 -0
  71. package/src/tool/mousePollingTest/logic/RatonManager.ts +97 -0
  72. package/src/tool/mousePollingTest/logic/StatisticalFilter.ts +48 -0
  73. package/src/tool/mousePollingTest/seo.astro +14 -0
  74. package/src/tool/mousePollingTest/ui.ts +8 -0
  75. package/src/tool/mousePollingTest/worker/mouseWorker.ts +50 -0
  76. package/src/tool/toneGenerator/bibliography.astro +14 -0
  77. package/src/tool/toneGenerator/component.astro +571 -0
  78. package/src/tool/toneGenerator/i18n/en.ts +146 -0
  79. package/src/tool/toneGenerator/i18n/es.ts +146 -0
  80. package/src/tool/toneGenerator/i18n/fr.ts +146 -0
  81. package/src/tool/toneGenerator/index.ts +34 -0
  82. package/src/tool/toneGenerator/seo.astro +14 -0
  83. package/src/tool/toneGenerator/ui.ts +19 -0
  84. package/src/tools.ts +10 -0
  85. package/src/types.ts +72 -0
@@ -0,0 +1,1419 @@
1
+ ---
2
+ import type { KnownLocale } from '../../types';
3
+ import type { TestMandoUI } from './ui';
4
+ import { Icon } from 'astro-icon/components';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ ui?: Record<string, unknown>;
9
+ }
10
+
11
+ const { ui } = Astro.props;
12
+ const t = (ui ?? {}) as TestMandoUI;
13
+ ---
14
+
15
+ <div id="gamepad-root" class="tm-wrapper"
16
+ data-s={JSON.stringify({c:t.gameLogConnected,cm:t.connectionMessage,d:t.gameLogDisconnected,cl:t.gameLogCleared,bp:t.gameLogBtnPrefix,vn:t.gameVibNotSupported,vl:t.gameVibLow,vh:t.gameVibHigh,ms:t.gameMoveStick,pr:t.gamePress,rln:t.rankLegendaryName,rld:t.rankLegendaryDesc,rlf:t.rankLegendaryFlavor,rpn:t.rankProName,rpd:t.rankProDesc,rpf:t.rankProFlavor,rgn:t.rankGamerName,rgd:t.rankGamerDesc,rgf:t.rankGamerFlavor,rnn:t.rankNoobName,rnd:t.rankNoobDesc,rnf:t.rankNoobFlavor,gst:t.gameShareText,gsp:t.gameShareScorePrefix,gss:t.gameShareScoreSuffix,gt:t.gameShareTitle})}>
17
+ <style is:global>
18
+ .tm-wrapper {
19
+ --tm-accent: #4f46e5;
20
+ --tm-accent-glow: rgba(79, 70, 229, 0.4);
21
+ --tm-surface: #fff;
22
+ --tm-surface-alt: #f8fafc;
23
+ --tm-surface-dark: #020617;
24
+ --tm-border: #e2e8f0;
25
+ --tm-text: #0f172a;
26
+ --tm-text-muted: #64748b;
27
+ --tm-btn-bg: #f1f5f9;
28
+ --tm-btn-border: #cbd5e1;
29
+ --tm-active: #0ea5e9;
30
+ --tm-active-glow: rgba(14, 165, 233, 0.5);
31
+ --tm-success: #10b981;
32
+ --tm-danger: #f43f5e;
33
+ --tm-kb-bg: #1e293b;
34
+ --tm-kb-border: #334155;
35
+ --tm-stick-base: #475569;
36
+ --tm-stick-cap: #334155;
37
+ --tm-stick-border: #64748b;
38
+ }
39
+
40
+ .theme-dark .tm-wrapper {
41
+ --tm-surface: #0f172a;
42
+ --tm-surface-alt: #1e293b;
43
+ --tm-border: #334155;
44
+ --tm-text: #f1f5f9;
45
+ --tm-text-muted: #94a3b8;
46
+ --tm-btn-bg: #1e293b;
47
+ --tm-btn-border: #475569;
48
+ --tm-kb-bg: #020617;
49
+ --tm-kb-border: #1e293b;
50
+ --tm-stick-base: #0f172a;
51
+ --tm-stick-cap: #475569;
52
+ --tm-stick-border: #334155;
53
+ }
54
+
55
+ body.is-widget .tm-wrapper {
56
+ padding: 0;
57
+ }
58
+
59
+ .tm-active-btn {
60
+ background-color: var(--tm-active);
61
+ border-color: var(--tm-active);
62
+ color: white;
63
+ box-shadow: 0 0 18px var(--tm-active-glow), 0 0 36px var(--tm-active-glow);
64
+ transform: scale(0.88);
65
+ }
66
+
67
+ .tm-active-dpad {
68
+ background-color: var(--tm-active);
69
+ box-shadow: 0 0 14px var(--tm-active-glow);
70
+ }
71
+
72
+ .tm-active-stick-cap {
73
+ background-color: var(--tm-active);
74
+ border-color: #7dd3fc;
75
+ box-shadow: 0 0 18px var(--tm-active-glow);
76
+ }
77
+
78
+ .tm-target-pop {
79
+ animation: tm-pop 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
80
+ }
81
+
82
+ @keyframes tm-pop {
83
+ 0% {
84
+ transform: scale(0) rotate(-10deg);
85
+ opacity: 0;
86
+ }
87
+ 100% {
88
+ transform: scale(1) rotate(0deg);
89
+ opacity: 1;
90
+ }
91
+ }
92
+
93
+ .tm-shake {
94
+ animation: tm-shake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
95
+ }
96
+
97
+ @keyframes tm-shake {
98
+ 10%, 90% { transform: translate3d(-1px, 0, 0); }
99
+ 20%, 80% { transform: translate3d(2px, 0, 0); }
100
+ 30%, 50%, 70% { transform: translate3d(-4px, 0, 0); }
101
+ 40%, 60% { transform: translate3d(4px, 0, 0); }
102
+ }
103
+ </style>
104
+
105
+ <div class="tm-container">
106
+
107
+ <div class="tm-card">
108
+ <div class="tm-card-header">
109
+ <div class="tm-badge">
110
+ <Icon name="mdi:gamepad-variant" class="tm-badge-icon" />
111
+ {t.badge}
112
+ </div>
113
+ <h2 class="tm-title">{t.title}</h2>
114
+ <p class="tm-description" id="connection-msg">{t.connectionMessage}</p>
115
+ </div>
116
+
117
+ <div class="tm-stage-wrapper">
118
+ <div id="gamepad-stage" class="tm-stage tm-stage-inactive">
119
+
120
+ <div class="tm-trigger tm-trigger-left">
121
+ <div class="tm-trigger-bar-track">
122
+ <div id="bar-6" class="tm-trigger-bar"></div>
123
+ </div>
124
+ <div id="btn-6" class="tm-trigger-btn"></div>
125
+ <span class="tm-trigger-label">LT / L2</span>
126
+ </div>
127
+
128
+ <div class="tm-trigger tm-trigger-right">
129
+ <div class="tm-trigger-bar-track">
130
+ <div id="bar-7" class="tm-trigger-bar"></div>
131
+ </div>
132
+ <div id="btn-7" class="tm-trigger-btn"></div>
133
+ <span class="tm-trigger-label">RT / R2</span>
134
+ </div>
135
+
136
+ <div id="btn-4" class="tm-shoulder tm-shoulder-left">LB</div>
137
+ <div id="btn-5" class="tm-shoulder tm-shoulder-right">RB</div>
138
+
139
+ <div class="tm-left-cluster">
140
+ <div class="tm-stick-base">
141
+ <div id="stick-left" class="tm-stick-cap">
142
+ <div id="btn-10" class="tm-stick-press"></div>
143
+ </div>
144
+ </div>
145
+ <div class="tm-dpad">
146
+ <div id="btn-12" class="tm-dpad-up"></div>
147
+ <div id="btn-13" class="tm-dpad-down"></div>
148
+ <div id="btn-14" class="tm-dpad-left"></div>
149
+ <div id="btn-15" class="tm-dpad-right"></div>
150
+ <div class="tm-dpad-center"></div>
151
+ </div>
152
+ </div>
153
+
154
+ <div class="tm-right-cluster">
155
+ <div class="tm-face-buttons">
156
+ <div id="btn-3" class="tm-face-btn">Y</div>
157
+ <div id="btn-1" class="tm-face-btn">B</div>
158
+ <div id="btn-0" class="tm-face-btn">A</div>
159
+ <div id="btn-2" class="tm-face-btn">X</div>
160
+ </div>
161
+ </div>
162
+
163
+ <div class="tm-stick-right">
164
+ <div class="tm-stick-base">
165
+ <div id="stick-right" class="tm-stick-cap">
166
+ <div id="btn-11" class="tm-stick-press"></div>
167
+ </div>
168
+ </div>
169
+ </div>
170
+
171
+ <div class="tm-center-buttons">
172
+ <div id="btn-8" class="tm-center-btn">SEL</div>
173
+ <div id="btn-9" class="tm-center-btn">STA</div>
174
+ </div>
175
+
176
+ <div id="btn-16" class="tm-power-btn">
177
+ <Icon name="mdi:power" class="tm-power-icon" />
178
+ </div>
179
+ </div>
180
+
181
+ <div id="info-bar" class="tm-info-bar tm-info-inactive">
182
+ <div class="tm-info-left">
183
+ <span>ID: <strong id="gp-index">--</strong></span>
184
+ <span id="gp-id" class="tm-gp-id">No device</span>
185
+ </div>
186
+ <div class="tm-info-right">
187
+ <span>{t.axisLabel}: <span id="axis-count">0</span></span>
188
+ <span>{t.buttonsLabel}: <span id="btn-count">0</span></span>
189
+ </div>
190
+ </div>
191
+ </div>
192
+
193
+ <div id="vibration-panel" class="tm-vibration-panel tm-vibration-inactive">
194
+ <div class="tm-vibration-info">
195
+ <h3 class="tm-vibration-title">
196
+ <Icon name="mdi:vibrate" class="tm-vibration-icon" />
197
+ {t.vibrationTitle}
198
+ </h3>
199
+ <p class="tm-vibration-desc">{t.vibrationDescription}</p>
200
+ </div>
201
+ <div class="tm-vibration-btns">
202
+ <button id="vib-low" class="tm-vib-btn">{t.vibrationLow}</button>
203
+ <button id="vib-high" class="tm-vib-btn">{t.vibrationHigh}</button>
204
+ </div>
205
+ </div>
206
+
207
+ <div class="tm-log-section">
208
+ <div class="tm-log-header">
209
+ <span class="tm-log-title">{t.eventHistoryTitle}</span>
210
+ <button id="clear-log" class="tm-log-clear">{t.eventHistoryClear}</button>
211
+ </div>
212
+ <div id="debug-log" class="tm-log">
213
+ <div class="tm-log-waiting">{t.eventWaiting}</div>
214
+ </div>
215
+ </div>
216
+ </div>
217
+
218
+ <div class="tm-game-card">
219
+ <div id="game-intro" class="tm-game-intro">
220
+ <div class="tm-game-icon">
221
+ <Icon name="mdi:controller" class="tm-game-icon-svg" />
222
+ </div>
223
+ <h2 class="tm-game-intro-title">{t.gameIntroTitlePre}<span class="tm-game-highlight">{t.gameIntroHighlight}</span>{t.gameIntroTitlePost}</h2>
224
+ <p class="tm-game-intro-desc">
225
+ {t.gameIntroDescPre}<strong>{t.gameIntroDescSeconds}</strong>{t.gameIntroDescPost}
226
+ </p>
227
+ <button id="start-game-btn" class="tm-game-start-btn">{t.gameStartBtn}</button>
228
+ <div id="game-controller-warning" class="tm-game-warning">
229
+ <Icon name="mdi:alert-circle" class="tm-game-warning-icon" />
230
+ {t.gameControllerWarning}
231
+ </div>
232
+ </div>
233
+
234
+ <div id="game-play" class="tm-game-play" style="display:none">
235
+ <div class="tm-game-hud">
236
+ <div class="tm-hud-item">
237
+ <span class="tm-hud-label">{t.gameTimer}</span>
238
+ <div class="tm-hud-value tm-hud-timer" id="game-timer">30.0s</div>
239
+ </div>
240
+ <div class="tm-hud-progress">
241
+ <div id="game-progress-bar" class="tm-progress-bar"></div>
242
+ </div>
243
+ <div class="tm-hud-item tm-hud-right">
244
+ <span class="tm-hud-label">{t.gameScore}</span>
245
+ <div class="tm-hud-value" id="game-score">000</div>
246
+ </div>
247
+ </div>
248
+
249
+ <div id="target-container" class="tm-target-container">
250
+ <div class="tm-target-ring"></div>
251
+ <div class="tm-target-ring-inner"></div>
252
+ <div id="target-button" class="tm-target-btn tm-target-hidden">
253
+ <span id="target-label">?</span>
254
+ <span id="target-sub" class="tm-target-sub">PRESS</span>
255
+ </div>
256
+ <div id="hit-flash" class="tm-flash tm-flash-hit"></div>
257
+ <div id="miss-flash" class="tm-flash tm-flash-miss"></div>
258
+ </div>
259
+
260
+ <div class="tm-game-instruction">
261
+ <div class="tm-instruction-dot"></div>
262
+ <span id="game-instruction">{t.gameInstruction}</span>
263
+ </div>
264
+ </div>
265
+
266
+ <div id="game-results" class="tm-game-results" style="display:none">
267
+ <div class="tm-trophy-wrapper">
268
+ <Icon name="mdi:trophy" class="tm-trophy" />
269
+ <div class="tm-new-record">{t.gameNewRecord}</div>
270
+ </div>
271
+ <h2 class="tm-results-title">{t.gameCompleted}</h2>
272
+ <div class="tm-final-score" id="final-score">0</div>
273
+ <p class="tm-game-result-feedback" id="game-feedback">{t.gameFeedback}</p>
274
+ <div class="tm-results-btns">
275
+ <button id="restart-game-btn" class="tm-restart-btn">{t.gameRestartBtn}</button>
276
+ <button id="share-btn" class="tm-share-btn">{t.gameShareBtn}</button>
277
+ </div>
278
+ </div>
279
+ </div>
280
+
281
+ </div>
282
+
283
+ <style>
284
+ .tm-wrapper {
285
+ width: 100%;
286
+ color: var(--tm-text);
287
+ }
288
+
289
+ .tm-container {
290
+ max-width: 56rem;
291
+ margin: 0 auto;
292
+ padding: 1rem;
293
+ display: flex;
294
+ flex-direction: column;
295
+ gap: 3rem;
296
+ }
297
+
298
+ .tm-card {
299
+ background: var(--tm-surface);
300
+ border-radius: 1.5rem;
301
+ padding: 2rem;
302
+ box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
303
+ border: 1px solid var(--tm-border);
304
+ }
305
+
306
+ .tm-card-header {
307
+ text-align: center;
308
+ margin-bottom: 2rem;
309
+ }
310
+
311
+ .tm-badge {
312
+ display: inline-flex;
313
+ align-items: center;
314
+ gap: 0.5rem;
315
+ padding: 0.25rem 0.75rem;
316
+ border-radius: 9999px;
317
+ background-color: rgba(79, 70, 229, 0.1);
318
+ color: var(--tm-accent);
319
+ font-size: 0.75rem;
320
+ font-weight: 700;
321
+ text-transform: uppercase;
322
+ letter-spacing: 0.05em;
323
+ margin-bottom: 0.75rem;
324
+ }
325
+
326
+ .tm-badge-icon {
327
+ width: 1rem;
328
+ height: 1rem;
329
+ }
330
+
331
+ .tm-title {
332
+ font-size: 1.875rem;
333
+ font-weight: 900;
334
+ letter-spacing: -0.02em;
335
+ margin: 0 0 0.25rem;
336
+ }
337
+
338
+ .tm-description {
339
+ font-size: 0.9375rem;
340
+ color: var(--tm-text-muted);
341
+ margin: 0;
342
+ }
343
+
344
+ .tm-stage-wrapper {
345
+ display: flex;
346
+ flex-direction: column;
347
+ align-items: center;
348
+ gap: 1rem;
349
+ }
350
+
351
+ .tm-stage {
352
+ position: relative;
353
+ width: 600px;
354
+ height: 420px;
355
+ transition: all 500ms;
356
+ }
357
+
358
+ .tm-stage-inactive {
359
+ opacity: 0.25;
360
+ filter: grayscale(1);
361
+ transform: scale(0.9);
362
+ }
363
+
364
+ @media (min-width: 640px) {
365
+ .tm-stage-inactive {
366
+ transform: scale(1);
367
+ }
368
+ }
369
+
370
+ .tm-trigger {
371
+ position: absolute;
372
+ top: 0;
373
+ width: 8rem;
374
+ height: 4rem;
375
+ background: var(--tm-btn-bg);
376
+ border-radius: 0.75rem 0.75rem 0 0;
377
+ border: 1px solid var(--tm-btn-border);
378
+ border-bottom: none;
379
+ display: flex;
380
+ flex-direction: column;
381
+ justify-content: flex-end;
382
+ padding: 0.5rem;
383
+ }
384
+
385
+ .tm-trigger-left { left: 2rem; }
386
+ .tm-trigger-right { right: 2rem; }
387
+
388
+ .tm-trigger-label {
389
+ position: absolute;
390
+ top: -1.5rem;
391
+ font-size: 0.625rem;
392
+ font-weight: 700;
393
+ color: var(--tm-text-muted);
394
+ }
395
+
396
+ .tm-trigger-left .tm-trigger-label { left: 0; }
397
+ .tm-trigger-right .tm-trigger-label { right: 0; }
398
+
399
+ .tm-trigger-bar-track {
400
+ width: 100%;
401
+ background: var(--tm-border);
402
+ height: 0.5rem;
403
+ border-radius: 9999px;
404
+ overflow: hidden;
405
+ }
406
+
407
+ .tm-trigger-bar {
408
+ height: 100%;
409
+ background: var(--tm-active);
410
+ width: 0;
411
+ transition: width 50ms;
412
+ }
413
+
414
+ .tm-trigger-btn {
415
+ margin-top: 0.5rem;
416
+ width: 100%;
417
+ height: 2rem;
418
+ border-radius: 0.5rem;
419
+ border: 2px solid var(--tm-btn-border);
420
+ transition: all 150ms;
421
+ }
422
+
423
+ .tm-shoulder {
424
+ position: absolute;
425
+ top: 5rem;
426
+ width: 8rem;
427
+ height: 2.5rem;
428
+ background: var(--tm-surface);
429
+ border-radius: 0.5rem;
430
+ border: 2px solid var(--tm-btn-border);
431
+ display: flex;
432
+ align-items: center;
433
+ justify-content: center;
434
+ font-size: 0.75rem;
435
+ font-weight: 700;
436
+ color: var(--tm-btn-border);
437
+ transition: all 150ms;
438
+ }
439
+
440
+ .tm-shoulder-left { left: 2rem; }
441
+ .tm-shoulder-right { right: 2rem; }
442
+
443
+ .tm-left-cluster {
444
+ position: absolute;
445
+ top: 8rem;
446
+ left: 4rem;
447
+ width: 12rem;
448
+ height: 12rem;
449
+ }
450
+
451
+ .tm-stick-base {
452
+ position: absolute;
453
+ top: 0;
454
+ left: 0;
455
+ width: 6rem;
456
+ height: 6rem;
457
+ background: var(--tm-stick-base);
458
+ border-radius: 9999px;
459
+ border: 2px solid var(--tm-stick-border);
460
+ display: flex;
461
+ align-items: center;
462
+ justify-content: center;
463
+ box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.4);
464
+ }
465
+
466
+ .tm-left-cluster .tm-stick-base {
467
+ top: 0;
468
+ left: 0;
469
+ }
470
+
471
+ .tm-stick-cap {
472
+ width: 3.5rem;
473
+ height: 3.5rem;
474
+ background: var(--tm-stick-cap);
475
+ border-radius: 9999px;
476
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 2px 6px rgba(0, 0, 0, 0.5);
477
+ position: relative;
478
+ will-change: transform;
479
+ transition: background 150ms, box-shadow 150ms;
480
+ }
481
+
482
+ .tm-stick-press {
483
+ width: 100%;
484
+ height: 100%;
485
+ border-radius: 9999px;
486
+ border: 4px solid transparent;
487
+ transition: border-color 150ms;
488
+ }
489
+
490
+ .tm-dpad {
491
+ position: absolute;
492
+ bottom: 0;
493
+ right: 0;
494
+ width: 6rem;
495
+ height: 6rem;
496
+ }
497
+
498
+ .tm-dpad-up,
499
+ .tm-dpad-down,
500
+ .tm-dpad-left,
501
+ .tm-dpad-right {
502
+ position: absolute;
503
+ width: 2rem;
504
+ height: 2rem;
505
+ border-radius: 0.25rem;
506
+ background: var(--tm-btn-bg);
507
+ border: 2px solid var(--tm-btn-border);
508
+ transition: all 150ms;
509
+ }
510
+
511
+ .tm-dpad-up {
512
+ top: 0;
513
+ left: 50%;
514
+ transform: translateX(-50%);
515
+ }
516
+ .tm-dpad-down {
517
+ bottom: 0;
518
+ left: 50%;
519
+ transform: translateX(-50%);
520
+ }
521
+ .tm-dpad-left {
522
+ left: 0;
523
+ top: 50%;
524
+ transform: translateY(-50%);
525
+ }
526
+ .tm-dpad-right {
527
+ right: 0;
528
+ top: 50%;
529
+ transform: translateY(-50%);
530
+ }
531
+ .tm-dpad-center {
532
+ position: absolute;
533
+ inset: 2rem;
534
+ background: var(--tm-btn-bg);
535
+ border-radius: 0.25rem;
536
+ }
537
+
538
+ .tm-right-cluster {
539
+ position: absolute;
540
+ top: 8rem;
541
+ right: 4rem;
542
+ width: 6rem;
543
+ height: 6rem;
544
+ }
545
+
546
+ .tm-stick-right {
547
+ position: absolute;
548
+ bottom: 12rem;
549
+ right: 16rem;
550
+ }
551
+
552
+ .tm-face-buttons {
553
+ position: absolute;
554
+ top: 0;
555
+ right: 0;
556
+ width: 6rem;
557
+ height: 6rem;
558
+ }
559
+
560
+ .tm-face-btn {
561
+ position: absolute;
562
+ width: 2.5rem;
563
+ height: 2.5rem;
564
+ border-radius: 9999px;
565
+ border: 2px solid var(--tm-btn-border);
566
+ background: var(--tm-surface);
567
+ display: flex;
568
+ align-items: center;
569
+ justify-content: center;
570
+ font-weight: 700;
571
+ font-size: 0.75rem;
572
+ color: var(--tm-btn-border);
573
+ transition: all 150ms;
574
+ }
575
+
576
+ #btn-3 {
577
+ top: 0;
578
+ left: 50%;
579
+ transform: translateX(-50%);
580
+ color: #eab308;
581
+ border-color: rgba(234, 179, 8, 0.6);
582
+ }
583
+ #btn-1 {
584
+ right: 0;
585
+ top: 50%;
586
+ transform: translateY(-50%);
587
+ color: #f43f5e;
588
+ border-color: rgba(244, 63, 94, 0.6);
589
+ }
590
+ #btn-0 {
591
+ bottom: 0;
592
+ left: 50%;
593
+ transform: translateX(-50%);
594
+ color: #10b981;
595
+ border-color: rgba(16, 185, 129, 0.6);
596
+ }
597
+ #btn-2 {
598
+ left: 0;
599
+ top: 50%;
600
+ transform: translateY(-50%);
601
+ color: #0ea5e9;
602
+ border-color: rgba(14, 165, 233, 0.6);
603
+ }
604
+
605
+ .tm-center-buttons {
606
+ position: absolute;
607
+ top: 10rem;
608
+ left: 50%;
609
+ transform: translateX(-50%);
610
+ display: flex;
611
+ gap: 1.5rem;
612
+ }
613
+
614
+ .tm-center-btn {
615
+ width: 2rem;
616
+ height: 2rem;
617
+ border-radius: 9999px;
618
+ background: var(--tm-btn-bg);
619
+ display: flex;
620
+ align-items: center;
621
+ justify-content: center;
622
+ font-size: 0.625rem;
623
+ font-weight: 700;
624
+ color: var(--tm-text-muted);
625
+ transition: all 150ms;
626
+ }
627
+
628
+ .tm-power-btn {
629
+ position: absolute;
630
+ top: 6rem;
631
+ left: 50%;
632
+ transform: translateX(-50%);
633
+ width: 3rem;
634
+ height: 3rem;
635
+ border-radius: 9999px;
636
+ border: 4px solid var(--tm-btn-bg);
637
+ background: var(--tm-surface);
638
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
639
+ display: flex;
640
+ align-items: center;
641
+ justify-content: center;
642
+ transition: all 150ms;
643
+ }
644
+
645
+ .tm-power-icon {
646
+ width: 1.5rem;
647
+ height: 1.5rem;
648
+ color: var(--tm-btn-border);
649
+ }
650
+
651
+ .tm-info-bar {
652
+ width: 100%;
653
+ padding-top: 1.5rem;
654
+ border-top: 1px solid var(--tm-border);
655
+ display: flex;
656
+ justify-content: space-between;
657
+ align-items: center;
658
+ font-size: 0.75rem;
659
+ color: var(--tm-text-muted);
660
+ transition: opacity 500ms;
661
+ }
662
+
663
+ .tm-info-inactive { opacity: 0.5; }
664
+
665
+ .tm-info-left,
666
+ .tm-info-right {
667
+ display: flex;
668
+ gap: 1rem;
669
+ }
670
+
671
+ .tm-gp-id {
672
+ display: none;
673
+ }
674
+
675
+ @media (min-width: 768px) {
676
+ .tm-gp-id { display: inline; }
677
+ }
678
+
679
+ .tm-vibration-panel {
680
+ margin-top: 2rem;
681
+ background: var(--tm-surface-alt);
682
+ border-radius: 1rem;
683
+ padding: 1.5rem;
684
+ border: 1px solid var(--tm-border);
685
+ display: flex;
686
+ flex-direction: column;
687
+ gap: 1rem;
688
+ transition: opacity 500ms;
689
+ }
690
+
691
+ @media (min-width: 768px) {
692
+ .tm-vibration-panel {
693
+ flex-direction: row;
694
+ align-items: center;
695
+ justify-content: space-between;
696
+ }
697
+ }
698
+
699
+ .tm-vibration-inactive {
700
+ opacity: 0.5;
701
+ pointer-events: none;
702
+ }
703
+
704
+ .tm-vibration-title {
705
+ font-size: 0.9375rem;
706
+ font-weight: 700;
707
+ display: flex;
708
+ align-items: center;
709
+ gap: 0.5rem;
710
+ margin: 0 0 0.25rem;
711
+ }
712
+
713
+ .tm-vibration-icon {
714
+ width: 1.25rem;
715
+ height: 1.25rem;
716
+ color: #d946ef;
717
+ }
718
+
719
+ .tm-vibration-desc {
720
+ font-size: 0.75rem;
721
+ color: var(--tm-text-muted);
722
+ margin: 0;
723
+ }
724
+
725
+ .tm-vibration-btns {
726
+ display: flex;
727
+ gap: 1rem;
728
+ }
729
+
730
+ .tm-vib-btn {
731
+ padding: 0.5rem 1rem;
732
+ background: var(--tm-surface);
733
+ border: 1px solid var(--tm-btn-border);
734
+ border-radius: 0.5rem;
735
+ font-size: 0.875rem;
736
+ font-weight: 700;
737
+ cursor: pointer;
738
+ color: var(--tm-text);
739
+ transition: all 150ms;
740
+ }
741
+
742
+ .tm-vib-btn:hover {
743
+ border-color: var(--tm-accent);
744
+ color: var(--tm-accent);
745
+ }
746
+
747
+ .tm-log-section {
748
+ margin-top: 2rem;
749
+ background: var(--tm-surface-alt);
750
+ border-radius: 1rem;
751
+ padding: 1rem;
752
+ border: 1px solid var(--tm-border);
753
+ }
754
+
755
+ .tm-log-header {
756
+ display: flex;
757
+ justify-content: space-between;
758
+ align-items: center;
759
+ margin-bottom: 0.5rem;
760
+ }
761
+
762
+ .tm-log-title {
763
+ font-size: 0.75rem;
764
+ font-weight: 700;
765
+ text-transform: uppercase;
766
+ color: var(--tm-text-muted);
767
+ letter-spacing: 0.1em;
768
+ }
769
+
770
+ .tm-log-clear {
771
+ font-size: 0.625rem;
772
+ font-weight: 700;
773
+ text-transform: uppercase;
774
+ background: none;
775
+ border: none;
776
+ cursor: pointer;
777
+ color: var(--tm-text-muted);
778
+ }
779
+
780
+ .tm-log-clear:hover {
781
+ color: var(--tm-active);
782
+ }
783
+
784
+ .tm-log {
785
+ height: 10rem;
786
+ overflow-y: auto;
787
+ font-size: 0.75rem;
788
+ color: var(--tm-text-muted);
789
+ background: var(--tm-kb-bg);
790
+ padding: 0.75rem;
791
+ border-radius: 0.5rem;
792
+ border: 1px solid var(--tm-kb-border);
793
+ scrollbar-width: thin;
794
+ scrollbar-color: var(--tm-border) transparent;
795
+ }
796
+
797
+ .tm-log::-webkit-scrollbar {
798
+ width: 4px;
799
+ }
800
+
801
+ .tm-log::-webkit-scrollbar-thumb {
802
+ background: var(--tm-border);
803
+ border-radius: 2px;
804
+ }
805
+
806
+ .tm-log-waiting {
807
+ font-style: italic;
808
+ opacity: 0.5;
809
+ color: var(--tm-text-muted);
810
+ }
811
+
812
+ .tm-log div {
813
+ line-height: 1.8;
814
+ border-bottom: 1px solid rgba(255, 255, 255, 0.04);
815
+ padding-bottom: 0.125rem;
816
+ }
817
+
818
+ .tm-game-card {
819
+ background: var(--tm-surface-dark);
820
+ border-radius: 3rem;
821
+ padding: 2.5rem;
822
+ position: relative;
823
+ overflow: hidden;
824
+ min-height: 500px;
825
+ display: flex;
826
+ flex-direction: column;
827
+ align-items: center;
828
+ justify-content: center;
829
+ border: 1px solid rgba(255, 255, 255, 0.05);
830
+ color: white;
831
+ }
832
+
833
+ .tm-game-intro {
834
+ position: relative;
835
+ z-index: 10;
836
+ text-align: center;
837
+ }
838
+
839
+ .tm-game-icon {
840
+ display: inline-flex;
841
+ align-items: center;
842
+ justify-content: center;
843
+ width: 5rem;
844
+ height: 5rem;
845
+ border-radius: 1.5rem;
846
+ background: rgba(255, 255, 255, 0.05);
847
+ border: 1px solid rgba(255, 255, 255, 0.1);
848
+ margin-bottom: 1.5rem;
849
+ }
850
+
851
+ .tm-game-icon-svg {
852
+ width: 2.5rem;
853
+ height: 2.5rem;
854
+ color: var(--tm-success);
855
+ }
856
+
857
+ .tm-game-intro-title {
858
+ font-size: clamp(1.75rem, 5vw, 3rem);
859
+ font-weight: 900;
860
+ letter-spacing: -0.02em;
861
+ margin: 0 0 1rem;
862
+ }
863
+
864
+ .tm-game-highlight {
865
+ background: linear-gradient(to right, #34d399, #22d3ee);
866
+ -webkit-background-clip: text;
867
+ -webkit-text-fill-color: transparent;
868
+ background-clip: text;
869
+ }
870
+
871
+ .tm-game-intro-desc {
872
+ color: #94a3b8;
873
+ margin: 0 0 2.5rem;
874
+ max-width: 28rem;
875
+ font-size: 1.125rem;
876
+ line-height: 1.6;
877
+ }
878
+
879
+ .tm-game-start-btn {
880
+ position: relative;
881
+ padding: 1.25rem 2.5rem;
882
+ background: linear-gradient(to right, #10b981, #06b6d4);
883
+ border: none;
884
+ border-radius: 1rem;
885
+ font-weight: 900;
886
+ font-size: 1.25rem;
887
+ color: white;
888
+ cursor: pointer;
889
+ box-shadow: 0 10px 30px rgba(16, 185, 129, 0.3);
890
+ transition: all 300ms;
891
+ overflow: hidden;
892
+ }
893
+
894
+ .tm-game-start-btn:hover {
895
+ box-shadow: 0 15px 40px rgba(16, 185, 129, 0.5);
896
+ transform: scale(1.05);
897
+ }
898
+
899
+ .tm-game-start-btn:active {
900
+ transform: scale(0.95);
901
+ }
902
+
903
+ .tm-game-warning {
904
+ margin-top: 1.5rem;
905
+ font-size: 0.875rem;
906
+ font-weight: 500;
907
+ display: flex;
908
+ align-items: center;
909
+ justify-content: center;
910
+ gap: 0.5rem;
911
+ color: #94a3b8;
912
+ }
913
+
914
+ .tm-game-warning-icon {
915
+ width: 1rem;
916
+ height: 1rem;
917
+ }
918
+
919
+ .tm-game-play {
920
+ position: relative;
921
+ z-index: 10;
922
+ width: 100%;
923
+ flex-direction: column;
924
+ align-items: center;
925
+ }
926
+
927
+ .tm-game-hud {
928
+ display: flex;
929
+ justify-content: space-between;
930
+ align-items: center;
931
+ width: 100%;
932
+ margin-bottom: 3rem;
933
+ }
934
+
935
+ .tm-hud-item {
936
+ display: flex;
937
+ flex-direction: column;
938
+ }
939
+
940
+ .tm-hud-right {
941
+ align-items: flex-end;
942
+ }
943
+
944
+ .tm-hud-label {
945
+ font-size: 0.625rem;
946
+ text-transform: uppercase;
947
+ letter-spacing: 0.2em;
948
+ color: #64748b;
949
+ font-weight: 900;
950
+ }
951
+
952
+ .tm-hud-value {
953
+ font-size: 2.25rem;
954
+ font-weight: 900;
955
+ color: white;
956
+ }
957
+
958
+ .tm-hud-timer {
959
+ color: var(--tm-success);
960
+ }
961
+
962
+ .tm-hud-progress {
963
+ width: 3rem;
964
+ height: 0.25rem;
965
+ background: rgba(255, 255, 255, 0.1);
966
+ border-radius: 9999px;
967
+ overflow: hidden;
968
+ }
969
+
970
+ .tm-progress-bar {
971
+ height: 100%;
972
+ background: var(--tm-success);
973
+ width: 100%;
974
+ transition: width 100ms linear;
975
+ }
976
+
977
+ .tm-target-container {
978
+ position: relative;
979
+ width: 18rem;
980
+ height: 18rem;
981
+ display: flex;
982
+ align-items: center;
983
+ justify-content: center;
984
+ }
985
+
986
+ .tm-target-ring {
987
+ position: absolute;
988
+ inset: 0;
989
+ border-radius: 9999px;
990
+ border: 1px solid rgba(255, 255, 255, 0.05);
991
+ animation: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;
992
+ }
993
+
994
+ .tm-target-ring-inner {
995
+ position: absolute;
996
+ inset: 1rem;
997
+ border-radius: 9999px;
998
+ border: 1px solid rgba(255, 255, 255, 0.1);
999
+ }
1000
+
1001
+ .tm-target-btn {
1002
+ width: 10rem;
1003
+ height: 10rem;
1004
+ border-radius: 1.5rem;
1005
+ border: 2px solid rgba(255, 255, 255, 0.2);
1006
+ display: flex;
1007
+ flex-direction: column;
1008
+ align-items: center;
1009
+ justify-content: center;
1010
+ font-size: 3.75rem;
1011
+ font-weight: 900;
1012
+ transition: all 150ms;
1013
+ }
1014
+
1015
+ .tm-target-hidden {
1016
+ transform: scale(0);
1017
+ }
1018
+
1019
+ .tm-target-sub {
1020
+ font-size: 0.625rem;
1021
+ text-transform: uppercase;
1022
+ letter-spacing: 0.1em;
1023
+ opacity: 0.4;
1024
+ }
1025
+
1026
+ .tm-flash {
1027
+ position: absolute;
1028
+ inset: 0;
1029
+ border-radius: 9999px;
1030
+ opacity: 0;
1031
+ pointer-events: none;
1032
+ transition: opacity 150ms;
1033
+ }
1034
+
1035
+ .tm-flash-hit {
1036
+ background: rgba(16, 185, 129, 0.3);
1037
+ filter: blur(3rem);
1038
+ }
1039
+ .tm-flash-miss {
1040
+ background: rgba(244, 63, 94, 0.3);
1041
+ filter: blur(3rem);
1042
+ }
1043
+
1044
+ .tm-game-instruction {
1045
+ margin-top: 3rem;
1046
+ display: flex;
1047
+ align-items: center;
1048
+ gap: 0.75rem;
1049
+ padding: 0.75rem 1.5rem;
1050
+ background: rgba(255, 255, 255, 0.05);
1051
+ border-radius: 9999px;
1052
+ border: 1px solid rgba(255, 255, 255, 0.1);
1053
+ }
1054
+
1055
+ .tm-instruction-dot {
1056
+ width: 0.75rem;
1057
+ height: 0.75rem;
1058
+ border-radius: 9999px;
1059
+ background: var(--tm-success);
1060
+ animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
1061
+ }
1062
+
1063
+ .tm-game-results {
1064
+ position: relative;
1065
+ z-index: 10;
1066
+ text-align: center;
1067
+ }
1068
+
1069
+ .tm-trophy-wrapper {
1070
+ position: relative;
1071
+ display: inline-block;
1072
+ margin-bottom: 1.5rem;
1073
+ }
1074
+
1075
+ .tm-trophy {
1076
+ width: 6rem;
1077
+ height: 6rem;
1078
+ color: #facc15;
1079
+ filter: drop-shadow(0 0 30px rgba(250, 204, 21, 0.4));
1080
+ }
1081
+
1082
+ .tm-new-record {
1083
+ position: absolute;
1084
+ top: -0.5rem;
1085
+ right: -0.5rem;
1086
+ background: var(--tm-success);
1087
+ color: white;
1088
+ font-size: 0.625rem;
1089
+ font-weight: 900;
1090
+ padding: 0.25rem 0.5rem;
1091
+ border-radius: 0.5rem;
1092
+ }
1093
+
1094
+ .tm-results-title {
1095
+ font-size: 1.5rem;
1096
+ font-weight: 700;
1097
+ color: #94a3b8;
1098
+ margin: 0 0 0.5rem;
1099
+ }
1100
+
1101
+ .tm-final-score {
1102
+ font-size: 6rem;
1103
+ font-weight: 900;
1104
+ color: white;
1105
+ letter-spacing: -0.05em;
1106
+ margin-bottom: 1.5rem;
1107
+ line-height: 1;
1108
+ }
1109
+
1110
+ .tm-game-result-feedback {
1111
+ color: #cbd5e1;
1112
+ margin: 0 0 2.5rem;
1113
+ font-size: 1.125rem;
1114
+ }
1115
+
1116
+ .tm-results-btns {
1117
+ display: flex;
1118
+ flex-direction: column;
1119
+ gap: 1rem;
1120
+ justify-content: center;
1121
+ }
1122
+
1123
+ @media (min-width: 640px) {
1124
+ .tm-results-btns { flex-direction: row; }
1125
+ }
1126
+
1127
+ .tm-restart-btn {
1128
+ padding: 1rem 2rem;
1129
+ background: white;
1130
+ color: black;
1131
+ border: none;
1132
+ border-radius: 1rem;
1133
+ font-weight: 900;
1134
+ font-size: 1.125rem;
1135
+ cursor: pointer;
1136
+ transition: all 200ms;
1137
+ }
1138
+
1139
+ .tm-restart-btn:hover { transform: scale(1.05); }
1140
+ .tm-restart-btn:active { transform: scale(0.95); }
1141
+
1142
+ .tm-share-btn {
1143
+ padding: 1rem 2rem;
1144
+ background: rgba(255, 255, 255, 0.1);
1145
+ color: white;
1146
+ border: 1px solid rgba(255, 255, 255, 0.1);
1147
+ border-radius: 1rem;
1148
+ font-weight: 900;
1149
+ font-size: 1.125rem;
1150
+ cursor: pointer;
1151
+ transition: all 200ms;
1152
+ }
1153
+
1154
+ .tm-share-btn:hover { background: rgba(255, 255, 255, 0.2); }
1155
+
1156
+ @keyframes ping {
1157
+ 75%, 100% {
1158
+ transform: scale(2);
1159
+ opacity: 0;
1160
+ }
1161
+ }
1162
+
1163
+ @keyframes pulse {
1164
+ 50% { opacity: 0.5; }
1165
+ }
1166
+ </style>
1167
+
1168
+ <script>
1169
+ interface VibGamepad {
1170
+ vibrationActuator?: { playEffect: (t: string, p: object) => Promise<void> };
1171
+ }
1172
+ type GameTarget =
1173
+ | { label: string; color: string; index: number; type: 'button' }
1174
+ | { label: string; color: string; index: -1; type: 'axis'; axis: number; value: number };
1175
+ let rafId = 0;
1176
+ const STICK_RANGE = 25;
1177
+ const S: Record<string, string> = JSON.parse(document.getElementById('gamepad-root')?.dataset?.s ?? '{}');
1178
+ const LABEL_MOVE = S.ms ?? 'MOVER STICK';
1179
+ const LABEL_PRESS = S.pr ?? 'PULSA';
1180
+ const stage = document.getElementById('gamepad-stage');
1181
+ const infoBar = document.getElementById('info-bar');
1182
+ const connMsg = document.getElementById('connection-msg');
1183
+ const logEl = document.getElementById('debug-log');
1184
+ const clearLogBtn = document.getElementById('clear-log');
1185
+ const vibPanel = document.getElementById('vibration-panel');
1186
+ const btnVibLow = document.getElementById('vib-low');
1187
+ const btnVibHigh = document.getElementById('vib-high');
1188
+ const txtIndex = document.getElementById('gp-index');
1189
+ const txtId = document.getElementById('gp-id');
1190
+ const txtAxis = document.getElementById('axis-count');
1191
+ const txtBtn = document.getElementById('btn-count');
1192
+ const barL = document.getElementById('bar-6');
1193
+ const barR = document.getElementById('bar-7');
1194
+ clearLogBtn?.addEventListener('click', () => {
1195
+ if (logEl) logEl.innerHTML = `<div class="tm-log-waiting">${S.cl ?? 'Log limpiado...'}</div>`;
1196
+ });
1197
+ function addLog(msg: string) {
1198
+ if (!logEl) return;
1199
+ const time = new Date().toLocaleTimeString();
1200
+ const line = document.createElement('div');
1201
+ line.innerHTML = `<span style="opacity:0.5">[${time}]</span> ${msg}`;
1202
+ logEl.prepend(line);
1203
+ if (logEl.children.length > 50 && logEl.lastElementChild) logEl.lastElementChild.remove();
1204
+ }
1205
+ function triggerVibration(gpIndex: number, weak: number, strong: number) {
1206
+ const gp = (navigator.getGamepads ? navigator.getGamepads() : [])[gpIndex] as VibGamepad | null;
1207
+ if (!gp?.vibrationActuator) { addLog(`<span style="color:#f97316">${S.vn ?? 'Vibración no soportada.'}</span>`); return; }
1208
+ gp.vibrationActuator
1209
+ .playEffect('dual-rumble', { startDelay: 0, duration: 500, weakMagnitude: weak, strongMagnitude: strong })
1210
+ .then(() => addLog(`Vibración: ${weak > 0.5 ? (S.vl ?? 'Grave') : (S.vh ?? 'Agudo')}`))
1211
+ .catch((err: Error) => addLog(`<span style="color:#f43f5e">Error: ${err.message}</span>`));
1212
+ }
1213
+ function toggleConnectedUI(connected: boolean) {
1214
+ stage?.classList.toggle('tm-stage-inactive', !connected);
1215
+ infoBar?.classList.toggle('tm-info-inactive', !connected);
1216
+ vibPanel?.classList.toggle('tm-vibration-inactive', !connected);
1217
+ }
1218
+ function updateGpInfo(gp: Gamepad) {
1219
+ if (connMsg) connMsg.innerHTML = `<span style="color:#10b981;font-weight:700">${S.c ?? 'Conectado'}</span>`;
1220
+ if (txtIndex) txtIndex.textContent = gp.index.toString();
1221
+ if (txtId) txtId.textContent = gp.id;
1222
+ if (txtAxis) txtAxis.textContent = gp.axes.length.toString();
1223
+ if (txtBtn) txtBtn.textContent = gp.buttons.length.toString();
1224
+ }
1225
+ window.addEventListener('gamepadconnected', (e: GamepadEvent) => {
1226
+ const gp = e.gamepad;
1227
+ toggleConnectedUI(true); updateGpInfo(gp);
1228
+ addLog(`<span style="color:#10b981;font-weight:700">${S.c ?? 'Conectado'}:</span> ${gp.id}`);
1229
+ if (btnVibLow) btnVibLow.onclick = () => triggerVibration(gp.index, 1.0, 0.0);
1230
+ if (btnVibHigh) btnVibHigh.onclick = () => triggerVibration(gp.index, 0.0, 1.0);
1231
+ loop();
1232
+ });
1233
+ window.addEventListener('gamepaddisconnected', () => {
1234
+ cancelAnimationFrame(rafId); toggleConnectedUI(false);
1235
+ if (connMsg) connMsg.textContent = S.cm ?? 'Conecta tu dispositivo USB o Bluetooth';
1236
+ if (txtIndex) txtIndex.textContent = '--';
1237
+ addLog(`<span style="color:#f43f5e;font-weight:700">${S.d ?? 'Desconectado'}</span>`);
1238
+ });
1239
+ function loop() {
1240
+ const gp = (navigator.getGamepads ? navigator.getGamepads() : [])[0];
1241
+ if (gp) updateInput(gp); rafId = requestAnimationFrame(loop);
1242
+ }
1243
+ const prevButtons = new Array(17).fill(false);
1244
+ function trackBtnLog(i: number, pressed: boolean) {
1245
+ if (pressed && !prevButtons[i]) { addLog(`${S.bp ?? 'Botón'} <strong>${i}</strong>`); prevButtons[i] = true; }
1246
+ else if (!pressed && prevButtons[i]) { prevButtons[i] = false; }
1247
+ }
1248
+ function updateTrigger(i: number, value: number, el: Element) {
1249
+ const bar = i === 6 ? barL : barR;
1250
+ if (bar) (bar as HTMLElement).style.width = `${value * 100}%`;
1251
+ el.classList.toggle('tm-active-btn', value > 0.1);
1252
+ }
1253
+ function updateStickBtn(pressed: boolean, el: Element) {
1254
+ const p = el.querySelector('.tm-stick-press') || el;
1255
+ (p as HTMLElement).style.borderColor = pressed ? '#7dd3fc' : 'transparent';
1256
+ }
1257
+ function updateVisualButton(i: number, pressed: boolean, value: number) {
1258
+ const el = document.getElementById(`btn-${i}`);
1259
+ if (!el) return;
1260
+ trackBtnLog(i, pressed);
1261
+ if (i === 6 || i === 7) { updateTrigger(i, value, el); return; }
1262
+ if (i >= 12 && i <= 15) { el.classList.toggle('tm-active-dpad', pressed); return; }
1263
+ if (i === 10 || i === 11) { updateStickBtn(pressed, el); return; }
1264
+ el.classList.toggle('tm-active-btn', pressed);
1265
+ }
1266
+ function updateInput(gp: Gamepad) {
1267
+ gp.buttons.forEach((b, i) => updateVisualButton(i, b.pressed, b.value));
1268
+ const sL = document.getElementById('stick-left');
1269
+ if (sL) sL.style.transform = `translate(${(gp.axes[0] ?? 0) * STICK_RANGE}px, ${(gp.axes[1] ?? 0) * STICK_RANGE}px)`;
1270
+ const sR = document.getElementById('stick-right');
1271
+ if (sR) sR.style.transform = `translate(${(gp.axes[2] ?? 0) * STICK_RANGE}px, ${(gp.axes[3] ?? 0) * STICK_RANGE}px)`;
1272
+ }
1273
+ const BTN_KEY_MAP: Record<string, number> = { a: 0, b: 1, x: 2, y: 3, q: 4, e: 5 };
1274
+ const ARROW_KEY_MAP: Record<string, number> = { arrowup: 12, arrowdown: 13, arrowleft: 14, arrowright: 15 };
1275
+ window.addEventListener('keyup', (e) => {
1276
+ const idx = BTN_KEY_MAP[e.key.toLowerCase()] ?? ARROW_KEY_MAP[e.key.toLowerCase()] ?? -1;
1277
+ if (idx !== -1) updateVisualButton(idx, false, 0.0);
1278
+ });
1279
+ const BUTTON_MAP: GameTarget[] = [
1280
+ { label: 'A', color: '#10b981', index: 0, type: 'button' }, { label: 'B', color: '#f43f5e', index: 1, type: 'button' },
1281
+ { label: 'X', color: '#0ea5e9', index: 2, type: 'button' }, { label: 'Y', color: '#eab308', index: 3, type: 'button' },
1282
+ { label: '↑', color: '#6366f1', index: -1, type: 'axis', axis: 1, value: -1 }, { label: '↓', color: '#6366f1', index: -1, type: 'axis', axis: 1, value: 1 },
1283
+ { label: '←', color: '#6366f1', index: -1, type: 'axis', axis: 0, value: -1 }, { label: '→', color: '#6366f1', index: -1, type: 'axis', axis: 0, value: 1 },
1284
+ ];
1285
+ let gameActive = false;
1286
+ let score = 0;
1287
+ let timeLeft = 30;
1288
+ let currentTarget: GameTarget | null = null;
1289
+ let timerInterval: ReturnType<typeof setInterval> | null = null;
1290
+ let axisCooldown = false;
1291
+ const [introScreen, playScreen, resultsScreen, startBtn, restartBtn, shareBtn,
1292
+ timerEl, scoreEl, progressBar, targetBtn, targetLabel, targetSub,
1293
+ finalScoreEl, controllerWarning, hitFlash, missFlash] = [
1294
+ 'game-intro', 'game-play', 'game-results', 'start-game-btn', 'restart-game-btn', 'share-btn',
1295
+ 'game-timer', 'game-score', 'game-progress-bar', 'target-button', 'target-label', 'target-sub',
1296
+ 'final-score', 'game-controller-warning', 'hit-flash', 'miss-flash',
1297
+ ].map(id => document.getElementById(id));
1298
+ function checkGamepad() {
1299
+ const connected = [...navigator.getGamepads()].some(Boolean);
1300
+ if (startBtn) (startBtn as HTMLButtonElement).disabled = false;
1301
+ if (controllerWarning) controllerWarning.style.display = connected ? 'none' : 'flex';
1302
+ }
1303
+ setInterval(checkGamepad, 1000);
1304
+ function updateTimerUI() {
1305
+ if (timerEl) timerEl.textContent = `${timeLeft.toFixed(1)}s`;
1306
+ if (progressBar) (progressBar as HTMLElement).style.width = `${(timeLeft / 30) * 100}%`;
1307
+ if (timerEl) timerEl.style.color = timeLeft < 5 ? '#f43f5e' : '#10b981';
1308
+ }
1309
+ function spawnTarget() {
1310
+ currentTarget = BUTTON_MAP[Math.floor(Math.random() * BUTTON_MAP.length)] ?? null;
1311
+ if (!currentTarget || !targetBtn || !targetLabel || !targetSub) return;
1312
+ (targetBtn as HTMLElement).style.background = currentTarget.color;
1313
+ (targetBtn as HTMLElement).style.color = 'white';
1314
+ targetBtn.classList.remove('tm-target-hidden', 'tm-target-pop');
1315
+ void targetBtn.offsetWidth;
1316
+ targetBtn.classList.add('tm-target-pop');
1317
+ targetLabel.textContent = currentTarget.label;
1318
+ targetSub.textContent = currentTarget.type === 'axis' ? LABEL_MOVE : LABEL_PRESS;
1319
+ setTimeout(() => targetBtn.classList.remove('tm-target-pop'), 300);
1320
+ }
1321
+ function startTimer() {
1322
+ timeLeft = 30;
1323
+ updateTimerUI();
1324
+ timerInterval = setInterval(() => {
1325
+ timeLeft -= 0.1;
1326
+ if (timeLeft <= 0) { timeLeft = 0; endGame(); }
1327
+ updateTimerUI();
1328
+ }, 100);
1329
+ }
1330
+ const [RLN, RLD, RLF] = [S.rln ?? 'LEGENDARIO', S.rld ?? 'Tus dedos se mueven a la velocidad del sonido.', S.rlf ?? 'Mi mando vuela!'];
1331
+ const [RPN, RPD, RPF] = [S.rpn ?? 'PRO GAMER', S.rpd ?? 'Tienes reflejos de acero y un mando bien calibrado.', S.rpf ?? 'Mi mando vuela!'];
1332
+ const [RGN, RGD, RGF] = [S.rgn ?? 'GAMER', S.rgd ?? 'No está mal, pero para el competitivo te falta calle.', S.rgf ?? 'Casi lo tengo!'];
1333
+ const [RNN, RND, RNF] = [S.rnn ?? 'MANCO', S.rnd ?? '¿Seguro que tienes el mando encendido?', S.rnf ?? 'El mando me tiene manía!'];
1334
+ function getRank(s: number) {
1335
+ if (s > 50) return { name: RLN, desc: RLD, flavor: RLF };
1336
+ if (s > 35) return { name: RPN, desc: RPD, flavor: RPF };
1337
+ if (s > 20) return { name: RGN, desc: RGD, flavor: RGF };
1338
+ return { name: RNN, desc: RND, flavor: RNF };
1339
+ }
1340
+ function endGame() {
1341
+ gameActive = false; if (timerInterval) clearInterval(timerInterval as unknown as number);
1342
+ if (playScreen) playScreen.style.display = 'none';
1343
+ if (resultsScreen) resultsScreen.style.display = 'block';
1344
+ if (finalScoreEl) finalScoreEl.textContent = score.toString();
1345
+ const rank = getRank(score), feedbackEl = document.getElementById('game-feedback');
1346
+ if (feedbackEl) feedbackEl.innerHTML = `<span style="color:white;display:block;font-weight:900;font-size:1.25rem;margin-bottom:0.25rem">${rank.name}</span><span style="color:#94a3b8">${rank.desc}</span>`;
1347
+ }
1348
+ function flash(el: Element | null | undefined) {
1349
+ if (!el) return;
1350
+ (el as HTMLElement).style.opacity = '1';
1351
+ setTimeout(() => { (el as HTMLElement).style.opacity = '0'; }, 100);
1352
+ }
1353
+ function handleHit() {
1354
+ score++;
1355
+ if (scoreEl) scoreEl.textContent = score.toString().padStart(3, '0');
1356
+ flash(hitFlash);
1357
+ spawnTarget();
1358
+ }
1359
+ function handleMiss() {
1360
+ flash(missFlash);
1361
+ targetBtn?.classList.add('tm-shake');
1362
+ setTimeout(() => targetBtn?.classList.remove('tm-shake'), 400);
1363
+ }
1364
+ const KEY_BTN: Record<string, number> = { a: 0, b: 1, x: 2, y: 3 };
1365
+ const KEY_AXIS: Record<string, string> = { ArrowUp: '↑', ArrowDown: '↓', ArrowLeft: '←', ArrowRight: '→' };
1366
+ function checkGameKey(e: KeyboardEvent) {
1367
+ if (!currentTarget) return;
1368
+ const k = e.key.toLowerCase();
1369
+ const isBtn = currentTarget.type === 'button';
1370
+ const isHit = isBtn ? KEY_BTN[k] === currentTarget.index : KEY_AXIS[e.key] === currentTarget.label;
1371
+ const isMiss = isBtn ? k in KEY_BTN : e.key in KEY_AXIS;
1372
+ if (isHit) handleHit(); else if (isMiss) handleMiss();
1373
+ }
1374
+ window.addEventListener('keydown', (e) => {
1375
+ const idx = BTN_KEY_MAP[e.key.toLowerCase()] ?? ARROW_KEY_MAP[e.key.toLowerCase()] ?? -1;
1376
+ if (idx !== -1) { updateVisualButton(idx, true, 1.0); stage?.classList.remove('tm-stage-inactive'); }
1377
+ if (!gameActive) { if (e.key === 'Enter') startGame(); return; }
1378
+ checkGameKey(e);
1379
+ });
1380
+ shareBtn?.addEventListener('click', () => {
1381
+ const rank = getRank(score), url = window.location.href;
1382
+ const text = `${S.gsp ?? 'Hice'} ${score} ${S.gss ?? 'aciertos'}. ${rank.flavor} ${S.gst ?? '¿Me superas?'} ${url}`;
1383
+ if (navigator.share) navigator.share({ title: S.gt ?? 'Test de Mando', text, url }).catch(() => navigator.clipboard.writeText(text));
1384
+ else navigator.clipboard.writeText(text);
1385
+ });
1386
+ function startGame() {
1387
+ score = 0; gameActive = true;
1388
+ if (scoreEl) scoreEl.textContent = '000';
1389
+ if (introScreen) introScreen.style.display = 'none';
1390
+ if (resultsScreen) resultsScreen.style.display = 'none';
1391
+ if (playScreen) playScreen.style.display = 'flex';
1392
+ startTimer(); spawnTarget(); requestAnimationFrame(gameLoop);
1393
+ }
1394
+ const prevGameBtns = new Array(17).fill(false);
1395
+ function processGameBtns(gp: Gamepad) {
1396
+ gp.buttons.forEach((b, i) => {
1397
+ if (b.pressed && !prevGameBtns[i]) {
1398
+ prevGameBtns[i] = true;
1399
+ if (currentTarget?.type === 'button' && i === currentTarget.index) handleHit();
1400
+ else if (i < 4) handleMiss();
1401
+ } else if (!b.pressed) { prevGameBtns[i] = false; }
1402
+ });
1403
+ }
1404
+ function processGameAxes(gp: Gamepad) {
1405
+ if (!currentTarget || currentTarget.type !== 'axis' || axisCooldown) return;
1406
+ const val = gp.axes[currentTarget.axis] ?? 0;
1407
+ const hit = currentTarget.value > 0 ? val > 0.5 : val < -0.5;
1408
+ if (hit) { handleHit(); axisCooldown = true; setTimeout(() => (axisCooldown = false), 200); }
1409
+ }
1410
+ function gameLoop() {
1411
+ if (!gameActive) return;
1412
+ navigator.getGamepads().forEach(gp => { if (gp) { processGameBtns(gp); processGameAxes(gp); } });
1413
+ requestAnimationFrame(gameLoop);
1414
+ }
1415
+ startBtn?.addEventListener('click', startGame);
1416
+ restartBtn?.addEventListener('click', startGame);
1417
+ window.addEventListener('gamepadconnected', checkGamepad);
1418
+ </script>
1419
+ </div>