· 5 years ago · Jun 15, 2020, 09:54 AM
1/*
2 Unofficial gamepad support for the SpaceX ISS Docking Simulator:
3 https://iss-sim.spacex.com/
4
5 Recommended browsers:
6 Chrome
7 Firefox
8
9 Supported gamepads:
10 DualShock 4
11 XBox 360
12 MAYFLASH F300
13*/
14
15var gpList = [], // Connected gamepad objects
16 gpActive;
17var inst = document.getElementById("instructions"), // Instructions panel
18 sett = document.getElementById("settings"), // Settings panel
19 intr = document.getElementById("intro"), // Intro screen
20 succ = document.getElementById("success"), // Success overlay
21 fail = document.getElementById("fail"); // Failure overlay
22var anyConnected = false,
23 snapshotsDSC = [],
24 snapshotsREG = [];
25
26// Sim HTML button ID's
27var htmlIDs =
28[
29 "pitch-down-button", "yaw-right-button", "yaw-left-button",
30 "pitch-up-button", "roll-left-button", "roll-right-button",
31 "translate-backward-button", "translate-forward-button", "showControls",
32 "option-instructions", "toggle-translation", "toggle-rotation",
33 "translate-up-button", "translate-down-button", "translate-left-button",
34 "translate-right-button", "option-restart", "option-settings"
35],
36
37// Sim button names
38simNames =
39[
40 "Pitch down", "Yaw right", "Yaw left",
41 "Pitch up", "Roll left", "Roll right",
42 "Move backward", "Move forward", "Gamepad controls",
43 "Instructions", "Movement sensitivity", "Rotation sensitivity",
44 "Move up", "Move down", "Move left",
45 "Move right", "Restart", "Settings"
46],
47
48// Potential gamepad button names
49buttons =
50{
51 XB: // XB360 layout
52 [
53 "A", "B", "X", "Y",
54 "LB", "RB", "LT", "RT",
55 "Back", "Start", "LS", "RS",
56 "DPad-Up", "DPad-Down", "DPad-Left", "DPad-Right",
57 "Home"
58 ],
59 PS: // DS4 layout
60 [
61 "Cross", "Circle", "Square", "Triangle",
62 "L1", "R1", "L2", "R2",
63 "Share", "Options", "L3", "R3",
64 "DPad-Up", "DPad-Down", "DPad-Left", "DPad-Right",
65 "PS", "Touchpad"
66 ]
67};
68
69
70
71/*
72 Poll a specified gamepad
73*/
74function readGP(index)
75{
76 return navigator.getGamepads()[index];
77}
78
79
80
81/*
82 Count the number of connected gamepads
83*/
84function countGPs()
85{
86 return navigator.getGamepads().length;
87}
88
89
90
91/*
92 Show or hide the gamepad buttons panel
93*/
94function toggleBindPanel()
95{
96 let panel = document.getElementById("gpPanel");
97 panel.classList.toggle("out");
98}
99
100
101
102/*
103 Set up internal gamepad info. Each gamepad should have:
104 1. An index number
105 2. An array of button names
106 3. A previous-input timestamp
107 4. A current-input timestamp
108 5. An array of previous-input button states
109 6. An array of current-input button states
110 7. An array of button mappings
111
112 Then, set the index of the active gamepad, and do initial
113 setup and begin recording inputs if this is the first
114 gamepad. Finally, indicate the connected gamepad as
115 available.
116*/
117function registerGamepad(evt)
118{
119 // 1. Create an object to store gamepad data and insert it into the
120 // gamepad list at its gamepad index.
121 let gp = {};
122 gp.index = evt.gamepad.index;
123 gpList[gp.index] = gp;
124
125 // 2. Assign appropriate button names based on controller type
126 let idStr = evt.gamepad.id, vend, prod, key;
127
128 // Different browsers have different gamepad ID formats
129 if(idStr.toUpperCase().indexOf("XINPUT") != -1)
130 key = "XInput";
131 else
132 {
133 if(idStr.indexOf("Vend") != -1)
134 { // Chrome
135 vend = idStr.substring(idStr.indexOf("Vend") + 8, idStr.indexOf("Vend") + 12);
136 prod = idStr.substring(idStr.indexOf("Prod") + 9, idStr.indexOf("Prod") + 13);
137 }
138 else
139 { // Firefox
140 vend = idStr.substring(0,4);
141 prod = idStr.substring(5,9);
142 }
143
144 key = vend + " " + prod;
145 }
146
147 switch(key)
148 {
149 case "054c 09cc": // DS4
150 case "0079 1830": // MAYFLASH F300 direct input
151 gp.buttonNames = buttons.PS; break;
152 case "XInput": // XBox
153 case "046d c216": // Logitech direct input
154 case "0079 181c": // MAYFLASH F300 generic input
155 gp.buttonNames = buttons.XB; break;
156 default:
157 alert("Unknown gamepad! (ID: " + idStr + ")");
158 }
159
160 // 3, 4. Initialize button-state arrays
161 gp.pre = [];
162 gp.preTime;
163 gp.now = evt.gamepad.buttons;
164 gp.nowTime = evt.gamepad.timestamp;
165
166 // 5. Make the default button map
167 gp.buttonMap = [];
168 let len = gp.now.length,
169 idx = 0;
170 for(; idx < len; idx++)
171 gp.buttonMap[idx] = idx;
172
173 // -----------------------------------------------------
174
175 // Set the active gamepad index. Always prioritize the gamepad
176 // with the lowest index in case they connect out of order. This
177 // can be manually changed afterwards. If none are connected,
178 // clear the active gamepad index.
179 if(gpActive === undefined || gp.index < gpActive)
180 gpActive = gp.index;
181
182 // Add extra styles, make the binds pane, and set the initial
183 // active gamepad when the first gamepad is connected
184 if(!anyConnected)
185 {
186 appendGamepadStyle();
187 makeButtonsPanel();
188 anyConnected = true;
189 }
190
191 let choices = document.getElementById("gpChoices");
192
193 // Add an entry for this gamepad to the buttons panel
194 let newChoice = document.createElement("div");
195 newChoice.id = "gpChoice" + gp.index;
196 newChoice.innerHTML =
197 `<input type="radio" id="pad` + gp.index + `" name="activeChoice" value=` + gp.index + `>\n` +
198 `<label for="pad` + gp.index + `" class="gamepad-label">` + (gp.index + 1) + `</label>\n`
199
200 let sibling = choices.children[0];
201 while(sibling && sibling.id[8] < gp.index)
202 sibling = sibling.nextElementSibling;
203
204 choices.insertBefore(newChoice, sibling);
205
206 // Select the button for the active gamepad
207 choices.elements[gpActive].checked = true;
208
209 changeActiveGP();
210
211 // Indicate this gamepad is available
212 console.log(gp.index);
213 choices.elements[gp.index].nextElementSibling.classList.add("available");
214
215 // Erase the event snapshot
216 snapshotsREG[gp.index] = undefined;
217
218 console.log("Gamepad connected: ", gp);
219}
220
221
222
223/*
224 Clear out gamepad info when the gamepad is disconnected
225*/
226function destroyGamepad(evt)
227{
228 let evtIndex = evt.gamepad.index;
229
230 // Remove this gamepad from the list
231 gpList[evtIndex] = undefined;
232
233 // Indicate this gamepad is unavailable
234 let choices = document.getElementById("gpChoices").elements;
235 choices[evtIndex].nextElementSibling.classList.remove("available", "gamepad-choice");
236
237 // Or maybe remove it from the panel
238 document.getElementById("gpChoices").elements[evtIndex].parentElement.remove();
239
240 // Search for a replacement gamepad. For idx, get the
241 // gamepad at index idx and increment to idx+1. If the
242 // gamepad is undefined and if idx+1 is less than the
243 // length of the list, check again for idx+1.
244 let idx = 0, newActive;
245 while(!newActive && idx < gpList.length)
246 newActive = gpList[idx++];
247
248 // If no replacement gamepad is found, then no gamepads
249 // are connected. Delete the binds panel and remove
250 // the gamepad panel styles.
251 if(!newActive)
252 {
253 document.getElementById("gpPanel").remove();
254 document.getElementById("gamepadStyle").remove();
255 anyConnected = false;
256 }
257 else
258 // If the above check was false, then at least one gamepad
259 // is connected. If the active gamepad was the source of
260 // this disconnect event, set the found gamepad as its
261 // replacement.
262 if(evtIndex == gpActive)
263 {
264 // Gamepads may be destroyed out of order, so always
265 // prioritize the one with the lowest index.
266 gpActive = newActive.index;
267 choices[gpActive].checked = true;
268 changeActiveGP();
269 }
270
271 // Erase the event snapshot
272 snapshotsDSC[evtIndex] = undefined;
273
274 console.log("Gamepad disconnected: ", evt.gamepad.index);
275}
276
277
278
279/*
280 Make a panel for button rebinds
281*/
282function makeButtonsPanel()
283{
284 // The main container for binding listings
285 let panel = document.createElement("div");
286 panel.id = "gpPanel";
287
288 // Label for controller ID's
289 let head = document.createElement("p");
290 head.innerText = "CONTROLLER";
291 head.classList.add("panel-text");
292 panel.appendChild(head);
293
294 // A form for the active controller choices
295 let choices = document.createElement("form");
296 choices.name = "gamepadChoices";
297 choices.id = "gpChoices";
298 panel.appendChild(choices);
299 choices.addEventListener("input", changeActiveGP);
300
301 // Create radio buttons for setting the active controller
302 /*let pad = 0,
303 max = countGPs();
304 for(; pad < max; pad++)
305 {
306 choices.innerHTML +=
307 `<div id="gpChoice` + pad + `'">` +
308 ` <input type="radio" id="pad` + pad + `" name="activeChoice" value=` + pad + `>\n` +
309 ` <label for="pad` + pad + `" class="gamepad-label">` + (pad + 1) + `</label>\n` +
310 `</div>`;
311 }*/
312
313 // Select the button for the active gamepad
314 // choices.elements[gpActive].checked = true;
315
316 // Provide a brief description
317 let info = document.createElement("p");
318 info.id = "info";
319 info.classList.add("panel-text");
320 panel.appendChild(info);
321 info.innerText = "Click two buttons to swap their binds or click a target\n" +
322 "binding and press the controller button it should be\n" +
323 "bound to. Choose the same bind twice to cancel.";
324
325 // Make a table to display gamepad binds
326 let table = document.createElement("table");
327 table.id = "bindsTable";
328 panel.appendChild(table);
329 document.body.appendChild(panel);
330
331 // Add elements to display button and interaction names
332 fillButtons();
333
334 panel.classList.toggle("out");
335}
336
337
338
339/*
340 Change the active gamepad and call for any related updates
341*/
342function changeActiveGP()
343{
344 let src = document.getElementById("gpChoices"),
345 newIndex = src.activeChoice.value,
346 oldActive = document.getElementsByClassName("gamepad-choice")[0];
347
348 // If a gamepad is present at the new index...
349 if(gpList[newIndex])
350 {
351 // Clean up the old choice
352 if(oldActive)
353 oldActive.classList.remove("gamepad-choice");
354
355 // Set it as active and show its inputs in the binds panel.
356 gpActive = newIndex;
357 src.elements[gpActive].nextElementSibling.classList.add("gamepad-choice");
358 fillButtons();
359 }
360 else
361 // Revert the change.
362 {
363 src.elements[gpActive].checked = true;
364
365 // Flash the label in red to indicate unavailability.
366 src.elements[newIndex].nextElementSibling.classList.add("unavailable");
367 window.setTimeout(() =>
368 {
369 src.elements[newIndex].nextElementSibling.classList.remove("unavailable");
370 }, 0);
371 }
372}
373
374
375
376/*
377 Make an entry on the buttons pane for each
378 one on the gamepad
379*/
380function fillButtons()
381{
382 let table = document.getElementById("bindsTable"),
383 btn = 0,
384 numBtns = gpList[gpActive].now.length;
385
386 // Clear the table's contents
387 table.innerHTML = "<tbody></tbody>";
388
389 let tbody = table.children[0];
390 // For each button on the gamepad...
391 for(; btn < numBtns; btn++)
392 {
393 tbody.innerHTML +=
394 `<tr>
395 <td>
396 <div id="label` + btn + `" class="bind-label"></div>
397 </td>
398 <td>
399 <button id="bind` + btn + `" class="bind-button" onclick="rebindButton(event);"></button>
400 </td>
401 </tr>`;
402 }
403
404 // Fill the labels and buttons
405 setBinds();
406}
407
408
409
410/*
411 Swap the outputs of two gamepad buttons
412*/
413function rebindButton(evt)
414{
415 // The button that fired this event
416 let clickedBtn = evt.srcElement;
417
418 // Search for an already-primed bind
419 let primedBtn = document.getElementsByClassName("primed")[0];
420
421 // Prime the clicked bind
422 clickedBtn.classList.add("primed");
423
424 // If a previous primed bind exists...
425 if(primedBtn)
426 {
427 // ...And it's not the one that was clicked...
428 if(primedBtn != clickedBtn)
429 {
430 // ...Get the indices of the rows containing the two buttons...
431 let cRow = clickedBtn.parentElement.parentElement,
432 pRow = primedBtn.parentElement.parentElement,
433 rows = [...(cRow.parentElement.children)],
434 cIndex = rows.indexOf(cRow),
435 pIndex = rows.indexOf(pRow),
436 map = gpList[gpActive].buttonMap,
437 temp;
438
439 // ...Switch the button mappings...
440 temp = map[cIndex];
441 map[cIndex] = map[pIndex];
442 map[pIndex] = temp;
443
444 // ...And redo the bind entries.
445 setBinds();
446 }
447
448 // Unprime both buttons on the second click. The timeout is a
449 // gross but necessary workaround for proper css transitions.
450 window.setTimeout(() =>
451 {
452 clickedBtn.classList.remove("primed");
453 primedBtn.classList.remove("primed");
454 }, 0);
455 }
456}
457
458
459
460/*
461 Fill the binds panel with the button bindings.
462*/
463function setBinds()
464{
465 let items = document.getElementById("bindsTable").children[0].children,
466 gp = gpList[gpActive];
467 len = gp.now.length,
468 num = 0;
469
470 // For every button, label the binds with...
471 for(; num < len; num++)
472 {
473 let label = items[num].children[0].children[0],
474 button = items[num].children[1].children[0];
475
476 // ...Button names...
477 label.innerText = gp.buttonNames[num].toUpperCase();
478
479 // ...And sim interaction names.
480 button.innerText = simNames[gp.buttonMap[num]].toUpperCase();
481 }
482}
483
484
485
486/*
487 So long as a gamepad is present, process its inputs and
488 call self again for the next frame. Automatically breaks
489 the cycle when no gamepads are connected.
490*/
491function live()
492{
493 for(let i = 0; i < 4; i++)
494 {
495 // If a controller doesn't exist in this slot, check
496 // for a connect snapshot on the same slot
497 if(!gpList[i])
498 {
499 if(snapshotsREG[i])
500 registerGamepad(snapshotsREG[i]);
501 }
502 // If one does exist, check for a disconnect
503 // snapshot on the same slot
504 else
505 {
506 if(snapshotsDSC[i])
507 destroyGamepad(snapshotsDSC[i]);
508 }
509 }
510
511 // anyConnected is set true when the first gamepad connects,
512 // and is set false when the last gamepad disconnects. live()
513 // is only active when at least one gamepad is connected.
514 //
515 // At least, that's the ideal. Disconnecting a controller
516 // seems to end up in a failed snapshot state regardless, but
517 // this doesn't appear to impair script functionality for now.
518
519 // If any gamepads are connected...
520 if(anyConnected)
521 {
522 let gpIndex = 0,
523 numGPs = countGPs();
524
525 // Check all gamepad slots
526 for(; gpIndex < numGPs; gpIndex++)
527 {
528 let gp = gpList[gpIndex];
529
530 // If a gamepad exists in this slot...
531 if(gp)
532 {
533 let stateSnapshot = readGP(gpIndex);
534
535 // And the snapshot was successful...
536 if(stateSnapshot)
537 {
538 gp.pre = gp.now; // ...Store inputs from the previous frame...
539 gp.preTime = gp.nowTime; // ...And the timestamp...
540 gp.now = stateSnapshot.buttons; // ...Get current inputs...
541 gp.nowTime = stateSnapshot.timestamp; // ...And the timestamp...
542 processGP(gp); // ...And process new inputs.
543 }
544 else
545 if(!gp)
546 console.log("Warning: Gamepad not found.", gpIndex);
547 else
548 if(!stateSnapshot)
549 console.log("Warning: Failed to snapshot gamepad state. ", gpIndex);
550 }
551 }
552
553 // Repeat on next frame
554 requestAnimationFrame(live);
555 }
556}
557
558
559
560/*
561 Read the gamepad state
562*/
563function processGP(gp)
564{
565 let numBtns = gp.now.length,
566 btn = 0;
567
568 // If the gamepad timestamp has changed...
569 if(gp.preTime < gp.nowTime)
570 // Check each button for a state change, firing an event identifying
571 // the source gamepad's ID, the button's ID, and the button's pressed
572 // state if found
573 for(; btn < numBtns; btn++)
574 if(gp.now[btn].pressed != gp.pre[btn].pressed)
575 window.dispatchEvent(new CustomEvent("buttontoggled",
576 { "detail":
577 {
578 gpID: gp.index,
579 btnID: btn,
580 pressed: !!(gp.now[btn].pressed)
581 }
582 }));
583}
584
585
586
587/*
588 Do stuff when a button's state is toggled
589*/
590function processBtn(evt)
591{
592 // Gamepad ID, button ID, and button state
593 let gpID = evt.detail.gpID,
594 btnID = evt.detail.btnID,
595 pressed = evt.detail.pressed;
596
597 // Light up the corresponding control on the binds panel
598 document.getElementById("label" + btnID).classList[pressed ? "add" : "remove"]("pressed");
599
600 // Click something if the source button is on the
601 // active gamepad and if it was pressed
602 if(gpID == gpActive && pressed)
603 {
604 // Event data shorthands
605 let srcGP = gpList[gpID],
606 mapVal = srcGP.buttonMap[btnID];
607
608 // If a binding has been primed for a swap...
609 if(document.getElementsByClassName("primed")[0])
610 {
611 // Click the corresponding bind button.
612 document.getElementById("bind" + btnID).click();
613 }
614 else
615 // Toggle the binds panel when the toggle button is pressed
616 if(htmlIDs[mapVal] == "showControls")
617 toggleBindPanel();
618 else
619 // Interact with the sim when other, bound buttons are pressed
620 if(htmlIDs[mapVal] != "unbound")
621 {
622 // Interact with the sim windows based on their opacity, prioritizing:
623 // 1. Instructions
624 // 2. Settings screen
625 // 3. Success/failure/intro screens
626 // 4. Sim interaction
627 switch("1")
628 {
629 // If the instructions are open...
630 case inst.style.opacity:
631 {
632 // ...Use the identified button to...
633 switch(mapVal)
634 {
635 // ...Close the instructions...
636 case 1:
637 case 17: document.getElementsByClassName("modal-close")[0].click(); break;
638
639 // ...Go to the previous page...
640 case 4:
641 case 14: document.getElementById("arrow-prev").click(); break;
642
643 // ...Or go to the next page.
644 case 5:
645 case 15: document.getElementById("arrow-next").click(); break;
646 }
647 } break;
648
649 // If the settings are open...
650 case sett.style.opacity:
651 {
652 // ...Use the identified button to...
653 switch(mapVal)
654 {
655 // ...Close the instructions...
656 case 1:
657 case 9: document.getElementsByClassName("modal-close")[1].click(); break;
658
659 // ...Toggle gravity...
660 case 12: document.getElementById("setting-gravity").click(); break;
661
662 // ...Or toggle Earth.
663 case 13: document.getElementById("setting-earth").click(); break;
664 }
665 } break;
666
667 // If at the success or failure screens...
668 case succ.style.opacity:
669 case fail.style.opacity:
670 {
671 let elem = document.getElementById((succ.style.opacity ? "success" : "fail") + "-button");
672
673 // ...Use the identified button to...
674 switch(mapVal)
675 {
676 // ...Play again.
677 case 9: elem.click(); break;
678 }
679 } break;
680 // If at the intro screen...
681 case intr.style.opacity:
682 {
683 // ...Use the identified button to...
684 switch(mapVal)
685 {
686 // ...Start the game...
687 case 9: document.getElementById("begin-button").click(); break;
688
689 // ...Or open the instructions.
690 case 17: document.getElementById("instructions-button").click(); break;
691 }
692 } break;
693
694 // During normal interaction...
695 default:
696 // ...Click the sim control buttons.
697 document.getElementById(htmlIDs[mapVal]).click();
698 }
699 }
700 }
701}
702
703
704
705/*
706 Add styling for gamepad-related elements to the document header
707*/
708function appendGamepadStyle()
709{
710 let gamepadStyle = document.createElement("style");
711 gamepadStyle.id = "gamepadStyle";
712
713 gamepadStyle.innerHTML =
714 `#gpPanel {
715 position: absolute;
716 margin: 8px;
717 padding: 16px;
718 z-index: 9999;
719 border: solid rgba(069,082,083,0.7) 2px;
720 background: rgba(0,0,0,0.7);
721 transform: translate(calc(-100% - 16px));
722 transition-property: transform;
723 transition-duration: 0.1s;
724 transition-timing-function: ease-in-out;
725 height: calc(100vh - 16px - 32px - 4px);
726 overflow-y: scroll;
727 text-align: center;
728 }
729
730 #gpPanel::-webkit-scrollbar {
731 width: 12px;
732 opacity: 0.5;
733 }
734
735 #gpPanel::-webkit-scrollbar-thumb {
736 background: rgba(129,137,145);
737 border-radius: 3px;
738 }
739
740 #gpPanel::-webkit-scrollbar-track {
741 background: rgba(069,082,083,0.7);
742 }
743
744 #gpChoices {
745 display: flex !important;
746 justify-content: space-evenly;
747 }
748 #gpChoices > div > input[type="radio"]
749 {
750 display: none;
751 }
752
753 #bindsTable {
754 border-collapse: separate;
755 }
756
757 .gamepad-label {
758 display: inline-block;
759 width: 1em;
760 height: 1em;
761 font-size: 2em;
762 vertical-align: middle;
763 cursor: pointer;
764 background: rgba(021,028,035,0.9);
765 border: 2px solid rgba(069,082,083,0.9);
766 padding: 2px;
767 border-radius: 5px;
768 line-height: 1em;
769 outline: none;
770
771 transition-property: background, color, border;
772 transition-duration: 1s;
773 transition-timing-function: ease-out;
774 }
775 .unavailable {
776 transition: none;
777 background: rgba(127,021,0,0.6);
778 color: rgb(255,042,0);
779 border: 2px solid rgba(255,042,0,0.6);
780 }
781 .available {
782 background: rgba(018,105,126,0.6);
783 color: rgb(036,210,253);
784 border: 2px solid rgba(036,210,253,0.6);
785 }
786 .gamepad-choice {
787 transition: none;
788 background: rgba(021,127,0,0.6);
789 color: rgb(042,255,0);
790 border: 2px solid rgba(042,255,0,0.6);
791 }
792
793 .out {
794 transform: none !important;
795 }
796
797 .panel-text {
798 margin: 0;
799 padding: 16px 0;
800 }
801
802 .bind-button {
803 outline: none;
804 background: rgba(021,028,035,0.9);
805 color: inherit;
806 border: 2px solid rgba(069,082,083,0.9);
807 width: calc(100% - 2px);
808 margin: 2px;
809 padding: 8px;
810 border-radius: 5px;
811 cursor: pointer;
812
813 transition-property: background, color, border;
814 transition-duration: 1s;
815 transition-timing-function: ease-out;
816 }
817
818 .primed {
819 transition: none !important;
820 background: rgba(021,127,0,0.6);
821 color: rgb(042,255,0);
822 border: 2px solid rgba(042,255,0,0.6);
823 }
824
825 .bind-label {
826 margin: 2px;
827 margin-right: 14px;
828 padding: 6px;
829 border-radius: 5px;
830 }
831
832 .pressed {
833 background: rgba(018,105,126,0.6);
834 margin: 0;
835 margin-right: 12px;
836 color: rgb(036,210,253);
837 border: 2px solid rgba(036,210,253,0.6);
838 }
839
840 td {
841 padding: 1px;
842 }`;
843
844 document.head.appendChild(gamepadStyle);
845}
846
847
848
849/*
850 Register events for input
851*/
852window.addEventListener("gamepadconnected", event =>
853{
854 snapshotsREG[event.gamepad.index] = event;
855 if(!anyConnected)
856 live();
857});
858
859window.addEventListener("gamepaddisconnected", event =>
860{
861 snapshotsDSC[event.gamepad.index] = event;
862 // destroyGamepad(event);
863});
864
865window.addEventListener("buttontoggled", processBtn);