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