coherent-gameface-interaction-manager
Version:
Library for the most common UI interactions
7 lines (6 loc) • 18.6 kB
JavaScript
(function(h,u){typeof exports=="object"&&typeof module<"u"?module.exports=u():typeof define=="function"&&define.amd?define(u):(h=typeof globalThis<"u"?globalThis:h||self,h.spatialNavigation=u())})(this,(function(){"use strict";function h(f){return f*180/Math.PI}class u{constructor(){this.actions=[],this.keyboardFunctions=[],this.gamepadFunctions=[]}init(){window._IM||(window._IM=new u)}getKeys(e){return _IM.keyboardFunctions.filter(t=>t.keys.every(s=>e.includes(s)))}getKeysIndex(e){return _IM.keyboardFunctions.findIndex(t=>t.keys.every(s=>e.includes(s)))}getGamepadAction({actions:e,type:t}){return _IM.gamepadFunctions.find(s=>s.actions.every(i=>e.includes(i))&&s.type===t&&s.actions.length===e.length)}getGamepadActions(e,t=!0){return _IM.gamepadFunctions.filter(s=>s.actions.every(i=>e.includes(i))&&(t?s.actions.length===e.length:!0))}getGamepadActionIndex(e){return _IM.gamepadFunctions.findIndex(t=>t.actions.every(s=>e.includes(s)))}getAction(e){return _IM.actions.find(t=>t.name===e)}getActionIndex(e){return _IM.actions.findIndex(t=>t.name===e)}hasDuplicateCallback(e,t){return e.callbacks.some(s=>s===t)}addCallbackToEntry(e,t,s){if(this.hasDuplicateCallback(e,t)){const i=typeof t=="string"?"action":"function",n=typeof t=="string"?t:"(anonymous function)";return console.error(`Duplicate callback detected!
${s.identifier}
Type: '${s.type}'
Callback: ${n}
This ${i} is already registered for this combination and type. To update it, first remove with the .off() method.`)}return e.callbacks.push(t)}removeKeyboardFunction(e){const t=_IM.keyboardFunctions.indexOf(e);t!==-1&&_IM.keyboardFunctions.splice(t,1)}removeGamepadFunction(e){const t=_IM.gamepadFunctions.indexOf(e);t!==-1&&_IM.gamepadFunctions.splice(t,1)}}const o=new u;class k{register(e,t){if(o.getAction(e))return console.error(`The following action "${e}" is already registered!`);_IM.actions.push({name:e,callback:t})}remove(e){const t=o.getActionIndex(e);if(t===-1)return console.error(`${e} is not a registered action!`);_IM.actions.splice(t,1)}execute(e,t){const s=o.getAction(e);if(!s)return console.error(`${e} is not a registered action!`);s.callback(t)}}const T=new k,E={ALT:18,ARROW_DOWN:40,ARROW_LEFT:37,ARROW_RIGHT:39,ARROW_UP:38,BACKSPACE:8,CAPS_LOCK:20,CTRL:17,DELETE:46,END:35,ENTER:13,ESC:27,F1:112,F2:113,F3:114,F4:115,F5:116,F6:117,F7:118,F8:119,F9:120,F10:121,F11:122,F12:123,HOME:36,INSERT:45,NUM_LOCK:144,NUMPAD_ENTER:13,NUMPAD_DASH:109,NUMPAD_STAR:106,NUMPAD_DOT:110,NUMPAD_FORWARD_SLASH:111,NUMPAD_PLUS:107,NUMPAD_0:96,NUMPAD_1:97,NUMPAD_2:98,NUMPAD_3:99,NUMPAD_4:100,NUMPAD_5:101,NUMPAD_6:102,NUMPAD_7:103,NUMPAD_8:104,NUMPAD_9:105,PAGE_DOWN:34,PAGE_UP:33,PAUSE:19,PRINT_SCRN:44,SCROLL_LOCK:145,SHIFT:16,SPACEBAR:32,TAB:9,A:65,B:66,C:67,D:68,E:69,F:70,G:71,H:72,I:73,J:74,K:75,L:76,M:77,N:78,O:79,P:80,Q:81,R:82,S:83,T:84,U:85,V:86,W:87,X:88,Y:89,Z:90,1:49,2:50,3:51,4:52,5:53,6:54,7:55,8:56,9:57,0:48,QUOTE:222,DASH:189,COMMA:188,DOT:190,FORWARD_SLASH:191,SEMI_COLON:186,SQUARE_BRACKET_LEFT:219,SQUARE_BRACKET_RIGHT:221,BACKWARD_SLASH:220,BACKTICK:192,EQUAL:187,SYSTEM:91},F=Object.keys(E);class v{constructor(){this.eventListenerAttached=!1,this.keysPressed=new Set,this.onKeyDown=this.onKeyDown.bind(this),this.onKeyUp=this.onKeyUp.bind(this),window.KEYS||(window.KEYS=E)}on(e){const t=this.normalizeKeys(e.keys),s=t.filter(n=>!E[n]);if(s.length>0)return console.error(`The following keys [${s.join(", ")}] you have entered are incorrect! `);this.eventListenerAttached||(document.addEventListener("keydown",this.onKeyDown),document.addEventListener("keyup",this.onKeyUp),this.eventListenerAttached=!0),(e.type?Array.isArray(e.type)?e.type:[e.type]:["press"]).forEach(n=>{const a=o.getKeys(t).find(l=>l.type===n);if(a)return o.addCallbackToEntry(a,e.callback,{identifier:`Keys: [${t.join(", ")}]`,type:n});if(n==="lift"&&t.length>1)return console.error("You can only have a single key trigger an action on lift");_IM.keyboardFunctions.push({keys:t,type:n,callbacks:[e.callback]})})}off(e,t){e=this.normalizeKeys(e);const s=o.getKeys(e);if(s.length===0)return console.error("You are trying to remove a non-existent key combination!");if(t){const i=s.filter(n=>n.callbacks.includes(t));if(i.length===0)return console.error("You are trying to remove a non-existent callback from this key combination!");i.forEach(n=>{const r=n.callbacks.indexOf(t);n.callbacks.splice(r,1),n.callbacks.length===0&&o.removeKeyboardFunction(n)})}else s.forEach(i=>{o.removeKeyboardFunction(i)});_IM.keyboardFunctions.length===0&&(document.removeEventListener("keydown",this.onKeyDown),document.removeEventListener("keyup",this.onKeyUp),this.eventListenerAttached=!1)}onKeyDown(e){const t=this.keyCodeToString(e.keyCode);if(!t)return;this.keysPressed.add(t);const s=o.getKeys([...this.keysPressed]);s.length!==0&&s.forEach(i=>{i.type==="press"&&e.repeat||i.type!=="press"&&i.type!=="hold"||i.type==="hold"&&!e.repeat||this.executeCallbacks(e,i)})}onKeyUp(e){const t=this.keyCodeToString(e.keyCode);if(!t)return;this.keysPressed.delete(t);const s=o.getKeys([t]);s.length!==0&&s.forEach(i=>{i.type==="lift"&&i.keys.indexOf(t)!==-1&&this.executeCallbacks(e,i)})}keyCodeToString(e){return F.find(t=>E[t]===e)}normalizeKeys(e){const t=e.map(s=>{if(typeof s=="number"){const i=this.keyCodeToString(s);if(!i)throw new Error(`Invalid KeyCode: ${s}`);return i}return s.toUpperCase()});return[...new Set(t)]}executeCallbacks(e,t){t.callbacks.forEach(s=>{if(typeof s=="string")return T.execute(s,e);s(e)})}}const b=new v,d={FACE_BUTTON_DOWN:0,FACE_BUTTON_RIGHT:1,FACE_BUTTON_LEFT:2,FACE_BUTTON_TOP:3,LEFT_SHOULDER:4,RIGHT_SHOULDER:5,LEFT_SHOULDER_BOTTOM:6,RIGHT_SHOULDER_BOTTOM:7,SELECT:8,START:9,LEFT_ANALOGUE_STICK:10,RIGHT_ANALOGUE_STICK:11,PAD_UP:12,PAD_DOWN:13,PAD_LEFT:14,PAD_RIGHT:15,CENTER_BUTTON:16,aliases:{"face-button-down":"FACE_BUTTON_DOWN","face-button-left":"FACE_BUTTON_LEFT","face-button-right":"FACE_BUTTON_RIGHT","face-button-top":"FACE_BUTTON_TOP","left-shoulder":"LEFT_SHOULDER","right-shoulder":"RIGHT_SHOULDER","left-shoulder-bottom":"LEFT_SHOULDER_BOTTOM","right-shoulder-bottom":"RIGHT_SHOULDER_BOTTOM",select:"SELECT",start:"START","left-analogue-stick":"LEFT_ANALOGUE_STICK","right-analogue-stick":"RIGHT_ANALOGUE_STICK","pad-up":"PAD_UP","pad-down":"PAD_DOWN","pad-left":"PAD_LEFT","pad-right":"PAD_RIGHT","center-button":"CENTER_BUTTON","playstation.x":"FACE_BUTTON_DOWN","playstation.square":"FACE_BUTTON_LEFT","playstation.circle":"FACE_BUTTON_RIGHT","playstation.triangle":"FACE_BUTTON_TOP","playstation.l1":"LEFT_SHOULDER","playstation.r1":"RIGHT_SHOULDER","playstation.l2":"LEFT_SHOULDER_BOTTOM","playstation.r2":"RIGHT_SHOULDER_BOTTOM","playstation.share":"SELECT","playstation.options":"START","playstation.l3":"LEFT_ANALOGUE_STICK","playstation.r3":"RIGHT_ANALOGUE_STICK","playstation.d-pad-up":"PAD_UP","playstation.d-pad-down":"PAD_DOWN","playstation.d-pad-left":"PAD_LEFT","playstation.d-pad-right":"PAD_RIGHT","playstation.center":"CENTER_BUTTON","xbox.a":"FACE_BUTTON_DOWN","xbox.x":"FACE_BUTTON_LEFT","xbox.b":"FACE_BUTTON_RIGHT","xbox.y":"FACE_BUTTON_TOP","xbox.lb":"LEFT_SHOULDER","xbox.rb":"RIGHT_SHOULDER","xbox.lt":"LEFT_SHOULDER_BOTTOM","xbox.rt":"RIGHT_SHOULDER_BOTTOM","xbox.view":"SELECT","xbox.menu":"START","xbox.left-thumbstick":"LEFT_ANALOGUE_STICK","xbox.right-thumbstick":"RIGHT_ANALOGUE_STICK","xbox.d-pad-up":"PAD_UP","xbox.d-pad-down":"PAD_DOWN","xbox.d-pad-left":"PAD_LEFT","xbox.d-pad-right":"PAD_RIGHT","xbox.center":"CENTER_BUTTON"},axisAliases:["right.joystick","left.joystick","left.joystick.down","left.joystick.up","left.joystick.left","left.joystick.right","right.joystick.down","right.joystick.up","right.joystick.left","right.joystick.right"]},c=.9,C=["press","hold"];class D{constructor(){this._gamepadEnabled=!1,this._pollingInterval=200,this._pressedAction=null,this._pressedButtons=[],this.sanitizeAction=this.sanitizeAction.bind(this)}set enabled(e){this._gamepadEnabled=e,this._gamepadEnabled?this.startPolling():this.stopPolling()}set pollingInterval(e){this._pollingInterval=e,this._gamepadEnabled&&(this.pollingIntervalRef&&this.stopPolling(),this.startPolling())}on(e){const t=e.actions.map(this.sanitizeAction),s=d.axisAliases.some(r=>t.includes(r)),i=e.type&&C.includes(e.type)?e.type:"hold";if(i==="press"&&s)return console.error("You can't use an axis action with a 'press' type!");if(t.length>1&&s)return console.error("You can't use an axis action in a combination with a button action");const n=o.getGamepadAction({actions:t,type:i});if(n)return o.addCallbackToEntry(n,e.callback,{identifier:`Actions: [${t.join(", ")}]`,type:i});_IM.gamepadFunctions.push({actions:t,type:i,callbacks:[e.callback]})}off(e,t){const s=o.getGamepadActions(e.map(this.sanitizeAction));if(s.length===0)return console.error("You are trying to remove a non-existent action!");if(t){const i=s.filter(n=>n.callbacks.includes(t));if(i.length===0)return console.error("You are trying to remove a non-existent callback from this action!");i.forEach(n=>{const r=n.callbacks.indexOf(t);n.callbacks.splice(r,1),n.callbacks.length===0&&o.removeGamepadFunction(n)})}else s.forEach(i=>o.removeGamepadFunction(i))}startPolling(){this.pollingIntervalRef=setInterval(()=>{const e=navigator.getGamepads();e.length!==0&&e.forEach(t=>{t&&(this.handleButtons(t.buttons),this.handleJoysticks(t.axes))})},this._pollingInterval)}stopPolling(){this.pollingIntervalRef&&clearInterval(this.pollingIntervalRef)}handleButtons(e){const t=e.reduce((i,n,r)=>(n.pressed&&(i.buttonIndexes.push(r),i.buttons.push(n)),i),{buttonIndexes:[],buttons:[]}),s=o.getGamepadActions(t.buttonIndexes,!1);this._pressedAction&&(s.includes(this._pressedAction)||this.executeCallbacks(this._pressedAction,this._pressedButtons),this._pressedAction=null,this._pressedButtons=[]),s.forEach(i=>{if(i.type==="press"){this._pressedAction=i,this._pressedButtons=t.buttons;return}this.executeCallbacks(i,t.buttons)})}handleJoysticks(e){this.getJoystickActions().forEach(s=>{switch(s.actions[0]){case"left.joystick":return this.executeCallbacks(s,[e[0],e[1]]);case"right.joystick":return this.executeCallbacks(s,[e[2],e[3]]);case"left.joystick.down":if(e[1]>c)return this.executeCallbacks(s,[e[0],e[1]]);break;case"left.joystick.up":if(e[1]<-c)return this.executeCallbacks(s,[e[0],e[1]]);break;case"left.joystick.left":if(e[0]<-c)return this.executeCallbacks(s,[e[0],e[1]]);break;case"left.joystick.right":if(e[0]>c)return this.executeCallbacks(s,[e[0],e[1]]);break;case"right.joystick.down":if(e[3]>c)return this.executeCallbacks(s,[e[2],e[3]]);break;case"right.joystick.up":if(e[3]<-c)return this.executeCallbacks(s,[e[2],e[3]]);break;case"right.joystick.left":if(e[2]<-c)return this.executeCallbacks(s,[e[2],e[3]]);break;case"right.joystick.right":if(e[2]>c)return this.executeCallbacks(s,[e[2],e[3]]);break}})}sanitizeAction(e){if(typeof e=="number")return e;const t=e.toLowerCase();if(d.axisAliases.includes(t))return t;const s=d.aliases[t];if(s)return d[s];throw new Error(`You have entered a non-supported button alias: ${e}`)}getJoystickActions(){return _IM.gamepadFunctions.filter(e=>d.axisAliases.includes(e.actions[0]))}executeCallbacks(e,t){e.callbacks.forEach(s=>{if(typeof s=="string")return T.execute(s,t);s(t)})}}const m=new D,A=["down","up","left","right"],O={up:["arrow_up"],down:["arrow_down"],right:["arrow_right"],left:["arrow_left"]};class I{constructor(){this.enabled=!1,this.areas={default:this.createDefaultAreaState()},this.registeredKeys=new Set,this.clearCurrentActiveKeys=!1,this.overlapPercentage=.5,this.lastFocusedElement=null,this.paused=!1,this.syncLastFocused=e=>{const t=e.target;if(!(!this.enabled||!t))for(const s in this.areas){const i=this.areas[s];if(i.elements.includes(t)){i.lastFocusedElement=t,this.lastFocusedElement=t;break}}}}init(e=[],t){this.enabled||(this.enabled=!0,this.add(e),this.activeKeys=JSON.parse(JSON.stringify(O)),this.registerKeyActions(),window.addEventListener("focusin",this.syncLastFocused),t&&0<=t&&t<=1&&(this.overlapPercentage=t))}deinit(){this.enabled&&(this.enabled=!1,this.areas={default:this.createDefaultAreaState()},this.removeKeyActions(),this.overlapPercentage=.5,this.lastFocusedElement=null,window.removeEventListener("focusin",this.syncLastFocused))}add(e){if(this.enabled){if(e.every(t=>t instanceof HTMLElement)){e.forEach(t=>this.makeFocusable(t)),this.setNavigationAreaProperties("default",e);return}e.forEach(t=>{typeof t=="string"?this.handleString(t):this.handleObject(t)})}}remove(e="default"){if(this.enabled){if(!this.areas[e])return console.error(`The area '${e}' you are trying to remove doesn't exist`);this.areas[e].elements.forEach(t=>t.removeAttribute("tabindex")),delete this.areas[e]}}getLastFocused(e="default"){const t=this.areas[e]?.lastFocusedElement;if(t&&!document.contains(t)){this.areas[e].lastFocusedElement=void 0;return}return t}createDefaultAreaState(){return{elements:[],lastFocusedElement:void 0}}handleString(e){const t=[...document.querySelectorAll(e)];if(t.length===0)return console.error(`${e} is either not a correct selector or the element is not present in the DOM.`);t.forEach(this.makeFocusable),this.setNavigationAreaProperties("default",t)}handleObject(e){const t=e.elements.reduce((s,i)=>{if(i instanceof HTMLElement)return s.push(i),this.makeFocusable(i),s;const n=[...document.querySelectorAll(i)];return n.forEach(this.makeFocusable),s.push(...n),s},[]);if(t.length===0)return console.error(`${e.elements.join(", ")} are either not a correct selectors or the elements are not present in the DOM.`);this.areas[e.area]||(this.areas[e.area]=this.createDefaultAreaState()),this.setNavigationAreaProperties(e.area,t)}setNavigationAreaProperties(e,t){this.areas[e].elements.push(...t)}makeFocusable(e){e.setAttribute("tabindex","1")}getFocusableGroup(e,t){return t.reduce((s,i)=>{if(i!==e&&!i.hasAttribute("disabled")){const{x:n,y:r,height:a,width:l}=i.getBoundingClientRect();s.push({element:i,x:n,y:r,height:a,width:l})}return s},[])}getCurrentArea(e){return Object.values(this.areas).find(t=>{if(t.elements.includes(e))return!0})}getClosestToEdge(e,t){return t.reduce((s,i)=>{switch(e){case"down":return i.y<s.y?i:s;case"up":return i.y>s.y?i:s;case"right":return i.x<s.x?i:s;case"left":return i.x>s.x?i:s;default:return s}})}moveFocus(e){if(!this.enabled)return;const t=this.isActiveElementInGroup()&&document.activeElement||this.lastFocusedElement,s=this.getCurrentArea(t);if(!s)return console.error("The active element is not in a focusable area!");const{elements:i}=s,n=this.getFocusableGroup(t,i),{x:r,y:a,width:l,height:_}=t.getBoundingClientRect(),y={x:r,y:a,width:l,height:_};if(n.length===0)return;const p=this.filterGroupByCurrentAxis(e,n,y);if(!p.length)return;let g=this.findNextElement(e,p,y.x,y.y);g||(g=this.getClosestToEdge(e,p)),g&&(g.element.focus(),this.lastFocusedElement=g.element,s.lastFocusedElement=this.lastFocusedElement)}filterGroupByCurrentAxis(e,t,s){return t.filter(i=>e==="left"||e==="right"?this.isOverlappingX(s,i):this.isOverlappingY(s,i))}isOverlappingX(e,t){const s=Math.min(e.y+e.height,t.y+t.height),i=Math.max(e.y,t.y),n=Math.max(0,s-i),r=Math.min(e.height,t.height);return n/r>=this.overlapPercentage}isOverlappingY(e,t){const s=Math.min(e.x+e.width,t.x+t.width),i=Math.max(e.x,t.x),n=Math.max(0,s-i),r=Math.min(e.width,t.width);return n/r>=this.overlapPercentage}findNextElement(e,t,s,i){return t.reduce((n,r)=>{const a=r.x-s,l=r.y-i,_=h(Math.atan2(l,a));if(this.getDirectionAngle(e,_)){n||(n=r);const y=Math.hypot(a,l),p=Math.hypot(n.x-s,n.y-i);n=y<p?r:n}return n},null)}getDirectionAngle(e,t){switch(e){case"down":return t>0&&t<180;case"up":return t>-180&&t<0;case"left":return t<-90||t>90;case"right":return t>-90&&t<90}}registerKeyActions(){A.forEach(e=>{const t=()=>{this.paused||this.moveFocus(e)};T.register(`move-focus-${e}`,t);const s=this.activeKeys[e];for(const i of s)b.on({keys:[i],callback:`move-focus-${e}`,type:["press","hold"]}),this.registeredKeys.add(i);m.on({actions:[`playstation.d-pad-${e}`],callback:`move-focus-${e}`})})}resetKeys(){this.enabled&&(this.removeKeyActions(),this.activeKeys=JSON.parse(JSON.stringify(O)),this.registerKeyActions())}changeKeys(e,t={clearCurrentActiveKeys:!1}){if(!this.enabled)return;const s=Object.keys(e);if(s.length===0)return;const i=s.filter(n=>!A.includes(n));if(i.length>0)return console.error(`The following directions: [${i.join(", ")}] you have entered are incorrect! `);this.clearCurrentActiveKeys=t.clearCurrentActiveKeys,this.removeKeyActions();for(const n in this.activeKeys){const r=e[n];typeof r=="string"&&!this.activeKeys[n].includes(r)&&this.activeKeys[n].push(r.toUpperCase())}this.registerKeyActions()}removeKeyActions(){this.registeredKeys.size!==0&&(A.forEach(e=>{const t=`move-focus-${e}`;for(const s of this.activeKeys[e])b.off([s],t);T.remove(t),m.off([`playstation.d-pad-${e}`],t),this.clearCurrentActiveKeys&&(this.activeKeys[e]=[])}),this.registeredKeys.clear(),this.clearCurrentActiveKeys=!1)}focusFirst(e="default"){if(!this.enabled)return;const t=this.areas[e].elements;if(!t||t.length===0)return console.error(`The area '${e}' you are trying to focus doesn't exist or the spatial navigation hasn't been initialized`);if(this.lastFocusedElement=t.find(s=>!s.hasAttribute("disabled")),!this.lastFocusedElement)return console.error(`The area '${e}' you are trying to focus doesn't have any focusable elements`);this.lastFocusedElement.focus(),this.areas[e].lastFocusedElement=this.lastFocusedElement}focusLast(e="default"){if(!this.enabled)return;const t=this.areas[e].elements;if(!t||t.length===0)return console.error(`The area '${e}' you are trying to focus doesn't exist or the spatial navigation hasn't been initialized`);let s;for(let i=t.length-1;i>=0;i--)if(!t[i].hasAttribute("disabled")){s=t[i];break}if(this.lastFocusedElement=s,!this.lastFocusedElement)return console.error(`The area '${e}' you are trying to focus doesn't have any focusable elements`);this.lastFocusedElement.focus(),this.areas[e].lastFocusedElement=this.lastFocusedElement}switchArea(e){if(!this.enabled)return;const t=this.getLastFocused(e);if(!t||t.hasAttribute("disabled"))return this.focusFirst(e);t.focus(),this.lastFocusedElement=t}isElementInGroup(e){return Object.values(this.areas).some(t=>t.elements.includes(e))}isActiveElementInGroup(){return document.activeElement?this.isElementInGroup(document.activeElement):!1}clearFocus(){this.enabled&&this.isActiveElementInGroup()&&document.activeElement.blur()}pause(){this.enabled&&(this.paused=!0)}resume(){this.enabled&&(this.paused=!1)}}const L=new I;return o.init(),L}));
//# sourceMappingURL=spatial-navigation.min.js.map