├── .babelrc ├── .eslintrc ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── LICENSE ├── README.md ├── docs └── examples │ ├── BasicExample │ ├── index.html │ ├── scene.css │ ├── scene.css.map │ ├── scene.js │ ├── scene.js.LICENSE.txt │ └── scene.js.map │ └── RotatingTargetExample │ ├── images │ ├── earth_bump.jpg │ ├── earth_lights.jpg │ ├── earth_map.jpg │ └── earth_spec.jpg │ ├── index.html │ ├── scene.css │ ├── scene.css.map │ ├── scene.js │ ├── scene.js.LICENSE.txt │ └── scene.js.map ├── examples ├── BasicExample │ ├── index.html │ ├── scene.ts │ └── styles.scss └── RotatingTargetExample │ ├── images │ ├── earth_bump.jpg │ ├── earth_lights.jpg │ ├── earth_map.jpg │ └── earth_spec.jpg │ ├── index.html │ ├── scene.ts │ └── styles.scss ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── JoystickControls.ts ├── RotationJoystickControls.ts ├── __tests__ │ ├── JoystickControls.test.ts │ ├── RotationJoystickControls.test.ts │ └── __snapshots__ │ │ └── JoystickControls.test.ts.snap ├── helpers │ ├── __tests__ │ │ ├── degreesToRadians.ts │ │ ├── getPositionInScene.test.ts │ │ ├── isTouchOutOfBounds.test.ts │ │ └── userSwipedMoreThan.test.ts │ ├── degreesToRadians.ts │ ├── getPositionInScene.ts │ ├── isTouchOutOfBounds.ts │ └── userSwipedMoreThan.ts └── index.ts ├── tsconfig.json ├── typings └── global.d.ts ├── webpack.common.ts ├── webpack.dev.ts └── webpack.prod.ts /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-typescript" 5 | ], 6 | "plugins": [ 7 | [ 8 | "@babel/plugin-transform-runtime", 9 | { 10 | "regenerator": true 11 | } 12 | ] 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "ecmaVersion": 2020, 5 | "sourceType": "module" 6 | }, 7 | "plugins": [ 8 | "@typescript-eslint", 9 | "prettier" 10 | ], 11 | "extends": [ 12 | "plugin:@typescript-eslint/recommended", 13 | "prettier" 14 | ], 15 | "rules": { 16 | "@typescript-eslint/ban-ts-comment": "off" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ide 2 | /.idea 3 | 4 | # dependencies 5 | node_modules 6 | 7 | # testing 8 | coverage 9 | 10 | # production 11 | dist 12 | 13 | # logs 14 | *.log 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Mr Mo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # three-joystick 2 | An open source joystick that can be used to control a target in a three.js scene. 3 | 4 | # Installation 5 | `npm i three-joystick` 6 | 7 | # RotationJoystickControls Usage 8 | This class will add a joystick that can rotate a target object. 9 | [See demo here](https://fried-chicken.github.io/three-joystick/examples/RotatingTargetExample/index.html) 10 | [See example code here](https://github.com/Fried-Chicken/three-joystick/blob/master/examples/RotatingTargetExample/scene.ts) 11 | 12 | 1. Import the RotationJoystickControls class 13 | ```javascript 14 | import { RotationJoystickControls } from 'three-joystick'; 15 | ``` 16 | 2. Pass through your camera, scene and the target mesh you want to control 17 | ```javascript 18 | const joystickControls = new RotationJoystickControls( 19 | camera, 20 | scene, 21 | target, 22 | ); 23 | ``` 24 | 3. Invoke update in your animate loop 25 | ```javascript 26 | function animate() { 27 | requestAnimationFrame(animate); 28 | 29 | /** 30 | * Updates the rotation of the target mesh 31 | */ 32 | rotationJoystick.update(); 33 | 34 | renderer.render(scene, camera); 35 | } 36 | 37 | animate(); 38 | ``` 39 | 40 | # JoystickControls Usage 41 | This class will add a joystick that invokes a callback function with the delta x and delta y from the movement of the user. 42 | [See demo here](https://fried-chicken.github.io/three-joystick/examples/BasicExample/index.html) 43 | [See example code here](https://github.com/Fried-Chicken/three-joystick/blob/master/examples/BasicExample/scene.ts) 44 | 45 | 1. Import the JoystickControls class 46 | ```javascript 47 | import { JoystickControls } from 'three-joystick'; 48 | ``` 49 | 2. Pass through your camera and scene 50 | ```javascript 51 | const joystickControls = new JoystickControls( 52 | camera, 53 | scene, 54 | ); 55 | ``` 56 | 3. Invoke update in your animate loop 57 | ```javascript 58 | function animate() { 59 | requestAnimationFrame(animate); 60 | 61 | /** 62 | * Updates a callback function with the delta x and delta y of the users 63 | * movement 64 | */ 65 | joystickControls.update((movement) => { 66 | if (movement) { 67 | /** 68 | * The values reported back might be too large for your scene. 69 | * In that case you will need to control the sensitivity. 70 | */ 71 | const sensitivity = 0.0001; 72 | 73 | /** 74 | * Do something with the values, for example changing the position 75 | * of the object 76 | */ 77 | this.target.position.x += movement.moveX * sensitivity; 78 | this.target.position.y += movement.moveY * sensitivity; 79 | } 80 | }); 81 | 82 | renderer.render(scene, camera); 83 | } 84 | 85 | animate(); 86 | ``` 87 | 88 | -------------------------------------------------------------------------------- /docs/examples/BasicExample/index.html: -------------------------------------------------------------------------------- 1 | Basic Example
-------------------------------------------------------------------------------- /docs/examples/BasicExample/scene.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2010-2021 Three.js Authors 4 | * SPDX-License-Identifier: MIT 5 | */ 6 | -------------------------------------------------------------------------------- /docs/examples/RotatingTargetExample/images/earth_bump.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimonMo88/three-joystick/4ff015b04bdc2759799ad6bbb8b12ff1b18a5be1/docs/examples/RotatingTargetExample/images/earth_bump.jpg -------------------------------------------------------------------------------- /docs/examples/RotatingTargetExample/images/earth_lights.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimonMo88/three-joystick/4ff015b04bdc2759799ad6bbb8b12ff1b18a5be1/docs/examples/RotatingTargetExample/images/earth_lights.jpg -------------------------------------------------------------------------------- /docs/examples/RotatingTargetExample/images/earth_map.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimonMo88/three-joystick/4ff015b04bdc2759799ad6bbb8b12ff1b18a5be1/docs/examples/RotatingTargetExample/images/earth_map.jpg -------------------------------------------------------------------------------- /docs/examples/RotatingTargetExample/images/earth_spec.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimonMo88/three-joystick/4ff015b04bdc2759799ad6bbb8b12ff1b18a5be1/docs/examples/RotatingTargetExample/images/earth_spec.jpg -------------------------------------------------------------------------------- /docs/examples/RotatingTargetExample/index.html: -------------------------------------------------------------------------------- 1 | Perspective Camera Example
-------------------------------------------------------------------------------- /docs/examples/RotatingTargetExample/scene.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2010-2021 Three.js Authors 4 | * SPDX-License-Identifier: MIT 5 | */ 6 | -------------------------------------------------------------------------------- /examples/BasicExample/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Basic Example 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /examples/BasicExample/scene.ts: -------------------------------------------------------------------------------- 1 | import './styles.scss'; 2 | import * as THREE from 'three'; 3 | import { JoystickControls } from '../../src'; 4 | 5 | declare global { 6 | interface Window { basicExample: BasicExample; } 7 | } 8 | 9 | class BasicExample { 10 | element = document.getElementById('target'); 11 | scene = new THREE.Scene(); 12 | camera = new THREE.PerspectiveCamera(); 13 | renderer = new THREE.WebGLRenderer({ 14 | antialias: true, 15 | }); 16 | joystickControls: JoystickControls; 17 | target: THREE.Mesh; 18 | geometry = new THREE.SphereGeometry(1, 36, 36); 19 | material = new THREE.MeshPhongMaterial({ 20 | wireframe: true, 21 | color: 0xFFFFFF, 22 | }); 23 | light = new THREE.AmbientLight(0xFFFFFF); 24 | 25 | constructor() { 26 | this.target = new THREE.Mesh(this.geometry, this.material); 27 | this.joystickControls = new JoystickControls( 28 | this.camera, 29 | this.scene, 30 | ); 31 | this.setupScene(); 32 | } 33 | 34 | setupScene = () => { 35 | this.element?.appendChild(this.renderer.domElement); 36 | 37 | this.camera.position.z = 5; 38 | 39 | this.scene.add( 40 | this.camera, 41 | this.target, 42 | this.light, 43 | ); 44 | 45 | this.resize(); 46 | this.animate(); 47 | 48 | window.addEventListener('resize', this.resize); 49 | }; 50 | 51 | resize = () => { 52 | const width = this.element?.clientWidth || 0; 53 | const height = this.element?.clientHeight || 0; 54 | 55 | this.renderer.setSize(width, height); 56 | this.camera.aspect = width / height; 57 | this.camera.updateProjectionMatrix(); 58 | }; 59 | 60 | animate = () => { 61 | requestAnimationFrame(this.animate); 62 | 63 | this.joystickControls.update((movement) => { 64 | if (movement) { 65 | const sensitivity = 0.001; 66 | this.target.position.x += movement.moveX * sensitivity; 67 | this.target.position.y -= movement.moveY * sensitivity; 68 | } 69 | }); 70 | 71 | this.renderer.render(this.scene, this.camera); 72 | }; 73 | } 74 | 75 | window.addEventListener('load', () => { 76 | window.basicExample = new BasicExample(); 77 | }); 78 | -------------------------------------------------------------------------------- /examples/BasicExample/styles.scss: -------------------------------------------------------------------------------- 1 | @import '~bootstrap/scss/bootstrap'; 2 | 3 | html, 4 | body, 5 | #target { 6 | width: 100%; 7 | height: 100%; 8 | position: fixed; 9 | overflow: hidden; 10 | } 11 | -------------------------------------------------------------------------------- /examples/RotatingTargetExample/images/earth_bump.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimonMo88/three-joystick/4ff015b04bdc2759799ad6bbb8b12ff1b18a5be1/examples/RotatingTargetExample/images/earth_bump.jpg -------------------------------------------------------------------------------- /examples/RotatingTargetExample/images/earth_lights.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimonMo88/three-joystick/4ff015b04bdc2759799ad6bbb8b12ff1b18a5be1/examples/RotatingTargetExample/images/earth_lights.jpg -------------------------------------------------------------------------------- /examples/RotatingTargetExample/images/earth_map.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimonMo88/three-joystick/4ff015b04bdc2759799ad6bbb8b12ff1b18a5be1/examples/RotatingTargetExample/images/earth_map.jpg -------------------------------------------------------------------------------- /examples/RotatingTargetExample/images/earth_spec.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimonMo88/three-joystick/4ff015b04bdc2759799ad6bbb8b12ff1b18a5be1/examples/RotatingTargetExample/images/earth_spec.jpg -------------------------------------------------------------------------------- /examples/RotatingTargetExample/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Perspective Camera Example 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /examples/RotatingTargetExample/scene.ts: -------------------------------------------------------------------------------- 1 | import './styles.scss'; 2 | import * as THREE from 'three'; 3 | import { RotationJoystickControls } from '../../src'; 4 | // import JoystickControls from '../../src'; 5 | 6 | declare global { 7 | interface Window { rotatingExample: RotatingTargetExample; } 8 | } 9 | 10 | class RotatingTargetExample { 11 | element = document.getElementById('target'); 12 | scene = new THREE.Scene(); 13 | camera = new THREE.PerspectiveCamera(); 14 | renderer = new THREE.WebGLRenderer({ 15 | antialias: true, 16 | }); 17 | rotationJoystick: RotationJoystickControls; 18 | earth: THREE.Mesh; 19 | geometry = new THREE.SphereGeometry(1, 36, 36); 20 | material = new THREE.MeshPhongMaterial({ 21 | bumpMap: new THREE.TextureLoader().load('images/earth_bump.jpg'), 22 | bumpScale: 0.05, 23 | map: new THREE.TextureLoader().load('images/earth_map.jpg'), 24 | specularMap: new THREE.TextureLoader().load('images/earth_spec.jpg'), 25 | specular: new THREE.Color('grey') 26 | }); 27 | ambientLight = new THREE.AmbientLight(0xFFFFFF); 28 | light = new THREE.DirectionalLight(0xFFFFFF, 0.3); 29 | 30 | constructor() { 31 | this.earth = new THREE.Mesh(this.geometry, this.material); 32 | this.rotationJoystick = new RotationJoystickControls( 33 | this.camera, 34 | this.scene, 35 | this.earth, 36 | ); 37 | this.setupScene(); 38 | } 39 | 40 | setupScene = () => { 41 | this.element?.appendChild(this.renderer.domElement); 42 | 43 | this.camera.position.z = 5; 44 | 45 | this.light.position.x = 60; 46 | this.light.position.y = 60; 47 | this.light.position.z = 10000; 48 | 49 | this.scene.add( 50 | this.camera, 51 | this.earth, 52 | this.light, 53 | this.ambientLight, 54 | ); 55 | 56 | 57 | this.resize(); 58 | this.animate(); 59 | 60 | window.addEventListener('resize', this.resize); 61 | }; 62 | 63 | resize = () => { 64 | const width = this.element?.clientWidth || 0; 65 | const height = this.element?.clientHeight || 0; 66 | 67 | this.renderer.setSize(width, height); 68 | this.camera.aspect = width / height; 69 | this.camera.updateProjectionMatrix(); 70 | }; 71 | 72 | animate = () => { 73 | requestAnimationFrame(this.animate); 74 | 75 | this.earth.rotation.y += 0.001; 76 | 77 | this.rotationJoystick.update(); 78 | 79 | this.renderer.render(this.scene, this.camera); 80 | }; 81 | } 82 | 83 | window.addEventListener('load', () => { 84 | window.rotatingExample = new RotatingTargetExample(); 85 | }); 86 | -------------------------------------------------------------------------------- /examples/RotatingTargetExample/styles.scss: -------------------------------------------------------------------------------- 1 | @import '~bootstrap/scss/bootstrap'; 2 | 3 | html, 4 | body, 5 | #target { 6 | width: 100%; 7 | height: 100%; 8 | position: fixed; 9 | overflow: hidden; 10 | } 11 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rootDir: './', 3 | preset: 'ts-jest', 4 | testEnvironment: 'jsdom', 5 | collectCoverage: true, 6 | collectCoverageFrom: [ 7 | 'src/**/*.{js,jsx,ts,tsx}', 8 | '!src/**/index.{js,jsx,ts,tsx}', 9 | '!types/*.{js,jsx,ts,tsx}', 10 | ], 11 | }; 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "three-joystick", 3 | "version": "1.0.2", 4 | "description": "A joystick for three.js", 5 | "main": "dist/index.js", 6 | "module": "dist/index.js", 7 | "files": [ 8 | "dist", 9 | "LICENSE", 10 | "package.json", 11 | "README.md" 12 | ], 13 | "types": "dist/index.d.ts", 14 | "scripts": { 15 | "test": "jest --coverage", 16 | "lint": "eslint ./src --ext .ts --fix", 17 | "dev": "webpack serve --config webpack.dev.ts", 18 | "build": "webpack --config webpack.prod.ts", 19 | "tsc": "tsc --project tsconfig.json --declaration --emitDeclarationOnly", 20 | "prepare": "npm run build && npm run tsc" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "https://github.com/Fried-Chicken/three-joystick.git" 25 | }, 26 | "keywords": [ 27 | "three.js", 28 | "three", 29 | "canvas", 30 | "joystick" 31 | ], 32 | "author": "Fried-Chicken ", 33 | "license": "MIT", 34 | "bugs": { 35 | "url": "https://github.com/Fried-Chicken/three-joystick/issues" 36 | }, 37 | "homepage": "https://github.com/Fried-Chicken/three-joystick#readme", 38 | "devDependencies": { 39 | "@babel/core": "^7.12.10", 40 | "@babel/plugin-transform-runtime": "^7.12.10", 41 | "@babel/preset-env": "^7.12.11", 42 | "@babel/preset-typescript": "^7.12.7", 43 | "@babel/register": "^7.15.3", 44 | "@babel/runtime": "^7.12.5", 45 | "@types/copy-webpack-plugin": "^8.0.1", 46 | "@types/jest": "^27.0.1", 47 | "@types/mini-css-extract-plugin": "^2.2.0", 48 | "@types/three": "^0.131.0", 49 | "@types/webpack-dev-server": "^4.1.0", 50 | "@typescript-eslint/eslint-plugin": "^4.29.3", 51 | "@typescript-eslint/parser": "^4.11.1", 52 | "babel-loader": "^8.2.2", 53 | "bootstrap": "^5.1.0", 54 | "clean-webpack-plugin": "^4.0.0-alpha.0", 55 | "copy-webpack-plugin": "^9.0.1", 56 | "css-loader": "^6.2.0", 57 | "eslint": "^7.17.0", 58 | "eslint-config-prettier": "^8.3.0", 59 | "eslint-plugin-prettier": "^3.4.1", 60 | "eslint-webpack-plugin": "^3.0.1", 61 | "fork-ts-checker-webpack-plugin": "^6.3.2", 62 | "html-webpack-plugin": "^5.3.2", 63 | "jest": "^27.0.6", 64 | "mini-css-extract-plugin": "^2.2.0", 65 | "prettier-eslint": "^13.0.0", 66 | "sass": "^1.38.1", 67 | "sass-loader": "^12.1.0", 68 | "three": "^0.131.3", 69 | "ts-jest": "^27.0.5", 70 | "ts-node": "^10.2.0", 71 | "typescript": "^4.3.5", 72 | "webpack": "^5.51.1", 73 | "webpack-cli": "^4.8.0", 74 | "webpack-dev-server": "^4.0.0", 75 | "webpack-merge": "^5.8.0" 76 | }, 77 | "peerDependencies": { 78 | "three": "^0.104.0" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/JoystickControls.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import isTouchOutOfBounds from './helpers/isTouchOutOfBounds'; 3 | import degreesToRadians from './helpers/degreesToRadians'; 4 | import getPositionInScene from './helpers/getPositionInScene'; 5 | 6 | export class JoystickControls { 7 | /** 8 | * This is the three.js scene 9 | */ 10 | scene: THREE.Scene; 11 | /** 12 | * This is the three.js camera 13 | */ 14 | camera: THREE.PerspectiveCamera; 15 | /** 16 | * This is used to detect if the user has moved outside the 17 | * joystick base. It will snap the joystick ball to the bounds 18 | * of the base of the joystick 19 | */ 20 | joystickTouchZone = 75; 21 | /** 22 | * Anchor of the joystick base 23 | */ 24 | baseAnchorPoint: THREE.Vector2 = new THREE.Vector2(); 25 | /** 26 | * Current point of the joystick ball 27 | */ 28 | touchPoint: THREE.Vector2 = new THREE.Vector2(); 29 | /** 30 | * Function that allows you to prevent the joystick 31 | * from attaching 32 | */ 33 | preventAction: () => boolean = () => false; 34 | /** 35 | * True when user has begun interaction 36 | */ 37 | interactionHasBegan = false; 38 | /** 39 | * True when the joystick has been attached to the scene 40 | */ 41 | isJoystickAttached = false; 42 | /** 43 | * Setting joystickScale will scale the joystick up or down in size 44 | */ 45 | joystickScale = 15; 46 | 47 | constructor( 48 | camera: THREE.PerspectiveCamera, 49 | scene: THREE.Scene, 50 | ) { 51 | this.camera = camera; 52 | this.scene = scene; 53 | this.create(); 54 | } 55 | 56 | /** 57 | * Touch start event listener 58 | */ 59 | private handleTouchStart = (event: TouchEvent) => { 60 | if (this.preventAction()) { 61 | return; 62 | } 63 | 64 | const touch = event.touches.item(0); 65 | 66 | if (!touch) { 67 | return; 68 | } 69 | 70 | this.onStart(touch.clientX, touch.clientY); 71 | }; 72 | 73 | /** 74 | * Mouse down event listener 75 | */ 76 | private handleMouseDown = (event: MouseEvent) => { 77 | if (this.preventAction()) { 78 | return; 79 | } 80 | 81 | this.onStart(event.clientX, event.clientY); 82 | }; 83 | 84 | /** 85 | * Plots the anchor point 86 | */ 87 | private onStart = (clientX: number, clientY: number) => { 88 | this.baseAnchorPoint = new THREE.Vector2(clientX, clientY); 89 | this.interactionHasBegan = true; 90 | }; 91 | 92 | /** 93 | * Touch move event listener 94 | */ 95 | private handleTouchMove = (event: TouchEvent) => { 96 | if (this.preventAction()) { 97 | return; 98 | } 99 | 100 | const touch = event.touches.item(0); 101 | 102 | if (touch) { 103 | this.onMove(touch.clientX, touch.clientY); 104 | } 105 | }; 106 | 107 | /** 108 | * Mouse move event listener 109 | */ 110 | private handleMouseMove = (event: MouseEvent) => { 111 | if (this.preventAction()) { 112 | return; 113 | } 114 | 115 | this.onMove(event.clientX, event.clientY); 116 | }; 117 | 118 | /** 119 | * Updates the joystick position during user interaction 120 | */ 121 | private onMove = (clientX: number, clientY: number) => { 122 | if (!this.interactionHasBegan) { 123 | return; 124 | } 125 | 126 | this.touchPoint = new THREE.Vector2(clientX, clientY); 127 | 128 | const positionInScene = getPositionInScene( 129 | clientX, 130 | clientY, 131 | this.camera, 132 | this.joystickScale, 133 | ); 134 | 135 | if (!this.isJoystickAttached) { 136 | /** 137 | * If there is no base or ball, then we need to attach the joystick 138 | */ 139 | return this.attachJoystick(positionInScene); 140 | } 141 | 142 | this.updateJoystickBallPosition(clientX, clientY, positionInScene); 143 | }; 144 | 145 | /** 146 | * Handles the touchend and mouseup events 147 | */ 148 | private handleEventEnd = () => { 149 | if (!this.isJoystickAttached) { 150 | return; 151 | } 152 | 153 | this.onEnd(); 154 | }; 155 | 156 | /** 157 | * Clean up joystick when the user interaction has finished 158 | */ 159 | private onEnd = () => { 160 | const joystickBase = this.scene.getObjectByName('joystick-base'); 161 | const joyStickBall = this.scene.getObjectByName('joystick-ball'); 162 | 163 | if (joystickBase){ 164 | this.scene.remove(joystickBase); 165 | } 166 | 167 | if ( joyStickBall) { 168 | this.scene.remove(joyStickBall); 169 | } 170 | 171 | this.isJoystickAttached = false; 172 | this.interactionHasBegan = false; 173 | }; 174 | 175 | /** 176 | * Draws the joystick base and ball 177 | * 178 | * TODO: Add feature to allow an image to be loaded. 179 | * TODO: Add option to change color and size of the joystick 180 | */ 181 | private attachJoystickUI = ( 182 | name: string, 183 | position: THREE.Vector3, 184 | color: number, 185 | size: number, 186 | ) => { 187 | const zoomScale = 1 / this.camera.zoom; 188 | const geometry = new THREE.CircleGeometry(size * zoomScale, 72); 189 | const material = new THREE.MeshLambertMaterial({ 190 | color: color, 191 | opacity: 0.5, 192 | transparent: true, 193 | depthTest: false, 194 | }); 195 | const uiElement = new THREE.Mesh(geometry, material); 196 | 197 | uiElement.renderOrder = 1; 198 | uiElement.name = name; 199 | uiElement.position.copy(position); 200 | 201 | this.scene.add(uiElement); 202 | }; 203 | 204 | /** 205 | * Creates the ball and base of the joystick 206 | */ 207 | private attachJoystick = (positionInScene: THREE.Vector3) => { 208 | this.attachJoystickUI( 209 | 'joystick-base', 210 | positionInScene, 211 | 0xFFFFFF, 212 | 0.9, 213 | ); 214 | this.attachJoystickUI( 215 | 'joystick-ball', 216 | positionInScene, 217 | 0xCCCCCC, 218 | 0.5, 219 | ); 220 | 221 | this.isJoystickAttached = true; 222 | }; 223 | 224 | /** 225 | * Calculates if the touch point was outside the joystick and 226 | * either returns the joystick ball position bound to the perimeter of 227 | * the base, or the position inside the base. 228 | */ 229 | private getJoystickBallPosition = ( 230 | clientX: number, 231 | clientY: number, 232 | positionInScene: THREE.Vector3, 233 | ): THREE.Vector3 => { 234 | const touchWasOutsideJoystick = isTouchOutOfBounds( 235 | clientX, 236 | clientY, 237 | this.baseAnchorPoint, 238 | this.joystickTouchZone, 239 | ); 240 | 241 | if (touchWasOutsideJoystick) { 242 | /** 243 | * Touch was outside Base so restrict the ball to the base perimeter 244 | */ 245 | const angle = Math.atan2( 246 | clientY - this.baseAnchorPoint.y, 247 | clientX - this.baseAnchorPoint.x, 248 | ) - degreesToRadians(90); 249 | const xDistance = Math.sin(angle) * this.joystickTouchZone; 250 | const yDistance = Math.cos(angle) * this.joystickTouchZone; 251 | const direction = new THREE.Vector3(-xDistance, -yDistance, 0) 252 | .normalize(); 253 | const joyStickBase = this.scene.getObjectByName('joystick-base'); 254 | 255 | /** 256 | * positionInScene restricted to the perimeter of the joystick 257 | * base 258 | */ 259 | return (joyStickBase as THREE.Object3D).position.clone().add(direction); 260 | } 261 | 262 | /** 263 | * Touch was inside the Base so just set the joystick ball to that 264 | * position 265 | */ 266 | return positionInScene; 267 | }; 268 | 269 | /** 270 | * Attaches the joystick or updates the joystick ball position 271 | */ 272 | private updateJoystickBallPosition = ( 273 | clientX: number, 274 | clientY: number, 275 | positionInScene: THREE.Vector3, 276 | ) => { 277 | const joyStickBall = this.scene.getObjectByName('joystick-ball'); 278 | const joystickBallPosition = this.getJoystickBallPosition( 279 | clientX, 280 | clientY, 281 | positionInScene, 282 | ); 283 | 284 | /** 285 | * Inside Base so just copy the position 286 | */ 287 | joyStickBall?.position.copy(joystickBallPosition); 288 | }; 289 | 290 | /** 291 | * Calculates and returns the distance the user has moved the 292 | * joystick from the center of the joystick base. 293 | */ 294 | protected getJoystickMovement = (): TMovement | null => { 295 | if (!this.isJoystickAttached) { 296 | return null; 297 | } 298 | 299 | return { 300 | moveX: this.touchPoint.x - this.baseAnchorPoint.x, 301 | moveY: this.touchPoint.y - this.baseAnchorPoint.y, 302 | }; 303 | }; 304 | 305 | /** 306 | * Adds event listeners to the document 307 | */ 308 | public create = (): void => { 309 | window.addEventListener('touchstart', this.handleTouchStart); 310 | window.addEventListener('touchmove', this.handleTouchMove); 311 | window.addEventListener('touchend', this.handleEventEnd); 312 | window.addEventListener('mousedown', this.handleMouseDown); 313 | window.addEventListener('mousemove', this.handleMouseMove); 314 | window.addEventListener('mouseup', this.handleEventEnd); 315 | }; 316 | 317 | /** 318 | * Removes event listeners from the document 319 | */ 320 | public destroy = (): void => { 321 | window.removeEventListener('touchstart', this.handleTouchStart); 322 | window.removeEventListener('touchmove', this.handleTouchMove); 323 | window.removeEventListener('touchend', this.handleEventEnd); 324 | window.removeEventListener('mousedown', this.handleMouseDown); 325 | window.removeEventListener('mousemove', this.handleMouseMove); 326 | window.removeEventListener('mouseup', this.handleEventEnd); 327 | }; 328 | 329 | /** 330 | * function that updates the positioning, this needs to be called 331 | * in the animation loop 332 | */ 333 | public update = (callback?: (movement?: TMovement | null) => void): void => { 334 | const movement = this.getJoystickMovement(); 335 | 336 | callback?.(movement); 337 | }; 338 | } 339 | -------------------------------------------------------------------------------- /src/RotationJoystickControls.ts: -------------------------------------------------------------------------------- 1 | import { JoystickControls } from './JoystickControls'; 2 | import { PerspectiveCamera, Object3D, Quaternion, Scene, Vector3 } from 'three'; 3 | 4 | /** 5 | * A joystick controller that can be used to rotate a target mesh 6 | * in a scene 7 | */ 8 | export class RotationJoystickControls extends JoystickControls { 9 | /** 10 | * Target object to control 11 | */ 12 | public target: Object3D; 13 | /** 14 | * Used for scaling down the delta value of x and y 15 | * that is passed to the update function's call back. 16 | * You can use this to scale down user movement for controlling 17 | * the speed. 18 | */ 19 | public deltaScale = 0.001; 20 | /** 21 | * Used for determining which axis the up/down movement of 22 | * the joystick influences 23 | */ 24 | public verticalMovementAxis: Vector3 = new Vector3(1, 0, 0); 25 | /** 26 | * Used for determining which axis the left/right movement of 27 | * the joystick influences 28 | */ 29 | public horizontalMovementAxis: Vector3 = new Vector3(0, 1, 0); 30 | /** 31 | * This is a reference quarternion used for keeping track of the 32 | * movement 33 | */ 34 | public quaternion: Quaternion = new Quaternion(); 35 | 36 | constructor( 37 | camera: PerspectiveCamera, 38 | scene: Scene, 39 | target: Object3D, 40 | ) { 41 | super(camera, scene); 42 | this.target = target; 43 | } 44 | 45 | /** 46 | * Converts and applies the angle in radians provided, to the 47 | * vertical movement axis specified, in the reference quarternion. 48 | * 49 | * @param angleInRadians 50 | */ 51 | private rotateVerticalMovement = (angleInRadians: number) => { 52 | this.quaternion.setFromAxisAngle( 53 | this.verticalMovementAxis, angleInRadians, 54 | ); 55 | 56 | this.target.quaternion.premultiply( 57 | this.quaternion, 58 | ); 59 | }; 60 | 61 | /** 62 | * Converts and applies the angle in radians provided, to the 63 | * horizontal movement axis specified, in the reference quarternion. 64 | * 65 | * @param angleInRadians 66 | */ 67 | private rotateHorizontalMovement = (angleInRadians: number) => { 68 | this.quaternion.setFromAxisAngle( 69 | this.horizontalMovementAxis, angleInRadians, 70 | ); 71 | 72 | this.target.quaternion.premultiply( 73 | this.quaternion, 74 | ); 75 | }; 76 | 77 | /** 78 | * Call this function in the animate loop to update 79 | * the rotation of the target mesh 80 | */ 81 | public update = (): void => { 82 | const joystickMovement = this.getJoystickMovement(); 83 | 84 | if (joystickMovement) { 85 | this.rotateVerticalMovement( 86 | joystickMovement.moveY * this.deltaScale, 87 | ); 88 | 89 | this.rotateHorizontalMovement( 90 | joystickMovement.moveX * this.deltaScale, 91 | ); 92 | } 93 | }; 94 | } 95 | -------------------------------------------------------------------------------- /src/__tests__/JoystickControls.test.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import JoystickControls from '../JoystickControls'; 3 | 4 | enum TOUCH { 5 | START = 'touchstart', 6 | MOVE = 'touchmove', 7 | END = 'touchend', 8 | } 9 | 10 | enum MOUSE { 11 | DOWN = 'mousedown', 12 | MOVE = 'mousemove', 13 | UP = 'mouseup', 14 | } 15 | 16 | const fireTouchEvent = ( 17 | touchEventName: TOUCH, 18 | location?: { clientX: number, clientY: number }, 19 | ) => { 20 | const touchEnd = new TouchEvent(touchEventName, {}); 21 | touchEnd.touches.item = () => (location as Touch); 22 | window.dispatchEvent(touchEnd); 23 | }; 24 | 25 | const fireMouseEvent = ( 26 | mouseEventName: MOUSE, 27 | location?: { clientX: number, clientY: number }, 28 | ) => { 29 | const mouseEvent = new MouseEvent(mouseEventName, location); 30 | 31 | window.dispatchEvent(mouseEvent); 32 | }; 33 | 34 | jest.mock('../helpers/getPositionInScene', () => ({ 35 | __esModule: true, 36 | default: () => new THREE.Vector2(100, 100), 37 | })); 38 | 39 | describe('JoystickControls', () => { 40 | describe('touch events', () => { 41 | const scene = new THREE.Scene(); 42 | const camera = new THREE.PerspectiveCamera(); 43 | 44 | it('should not invoke `onStart` if the `preventAction` function returned `true`', () => { 45 | const controls = new JoystickControls( 46 | camera, 47 | scene, 48 | ); 49 | controls.preventAction = () => true; 50 | 51 | const spyOnStart = jest.spyOn( 52 | controls, 53 | // @ts-ignore 54 | 'onStart', 55 | ); 56 | 57 | fireTouchEvent( 58 | TOUCH.START, 59 | { 60 | clientX: 0, 61 | clientY: 0, 62 | }, 63 | ); 64 | 65 | expect(spyOnStart).not.toHaveBeenCalled(); 66 | }); 67 | 68 | it('should not invoke `onStart` if the touch is undefined', () => { 69 | const controls = new JoystickControls( 70 | camera, 71 | scene, 72 | ); 73 | const spyOnStart = jest.spyOn( 74 | controls, 75 | // @ts-ignore 76 | 'onStart', 77 | ); 78 | 79 | fireTouchEvent(TOUCH.START); 80 | 81 | expect(spyOnStart).not.toHaveBeenCalled(); 82 | }); 83 | 84 | it('should invoke `onStart` if the touch is defined', () => { 85 | const controls = new JoystickControls( 86 | camera, 87 | scene, 88 | ); 89 | const spyOnStart = jest.spyOn( 90 | controls, 91 | // @ts-ignore 92 | 'onStart', 93 | ); 94 | 95 | fireTouchEvent( 96 | TOUCH.START, 97 | { 98 | clientX: 0, 99 | clientY: 0, 100 | }, 101 | ); 102 | 103 | expect(spyOnStart).toHaveBeenCalledTimes(1); 104 | }); 105 | 106 | it('should not invoke `onMove` if the `preventAction` function returned `true`', () => { 107 | const controls = new JoystickControls( 108 | camera, 109 | scene, 110 | ); 111 | controls.preventAction = () => true; 112 | 113 | const spyOnMove = jest.spyOn( 114 | controls, 115 | // @ts-ignore 116 | 'onMove', 117 | ); 118 | 119 | fireTouchEvent( 120 | TOUCH.MOVE, 121 | { 122 | clientX: 0, 123 | clientY: 0, 124 | }, 125 | ); 126 | 127 | expect(spyOnMove).not.toHaveBeenCalled(); 128 | }); 129 | 130 | it('should not invoke `onMove` if the touch is undefined', () => { 131 | const controls = new JoystickControls( 132 | camera, 133 | scene, 134 | ); 135 | const spyOnMove = jest.spyOn( 136 | controls, 137 | // @ts-ignore 138 | 'onMove', 139 | ); 140 | 141 | fireTouchEvent(TOUCH.MOVE); 142 | 143 | expect(spyOnMove).not.toHaveBeenCalled(); 144 | }); 145 | 146 | it('should invoke `onMove` if the touch is defined', () => { 147 | const controls = new JoystickControls( 148 | camera, 149 | scene, 150 | ); 151 | const spyOnMove = jest.spyOn( 152 | controls, 153 | // @ts-ignore 154 | 'onMove', 155 | ); 156 | 157 | fireTouchEvent( 158 | TOUCH.MOVE, 159 | { 160 | clientX: 0, 161 | clientY: 0, 162 | }, 163 | ); 164 | 165 | expect(spyOnMove).toHaveBeenCalledTimes(1); 166 | }); 167 | 168 | it('should not invoke `onEnd` if the joystick is not attached', () => { 169 | const controls = new JoystickControls( 170 | camera, 171 | scene, 172 | ); 173 | const spyOnEnd = jest.spyOn( 174 | controls, 175 | // @ts-ignore 176 | 'onEnd', 177 | ); 178 | 179 | controls.isJoystickAttached = false; 180 | 181 | fireTouchEvent(TOUCH.END); 182 | 183 | expect(spyOnEnd).not.toHaveBeenCalled(); 184 | }); 185 | 186 | it('should invoke `onEnd` if the joystick is attached', () => { 187 | const controls = new JoystickControls( 188 | camera, 189 | scene, 190 | ); 191 | const spyOnEnd = jest.spyOn( 192 | controls, 193 | // @ts-ignore 194 | 'onEnd', 195 | ); 196 | 197 | controls.isJoystickAttached = true; 198 | 199 | fireTouchEvent(TOUCH.END); 200 | 201 | expect(spyOnEnd).toHaveBeenCalledTimes(1); 202 | }); 203 | }); 204 | 205 | describe('mouse events', () => { 206 | const scene = new THREE.Scene(); 207 | const camera = new THREE.PerspectiveCamera(); 208 | 209 | it('should not invoke `onStart` if the `preventAction` function returned `true`', () => { 210 | const controls = new JoystickControls( 211 | camera, 212 | scene, 213 | ); 214 | controls.preventAction = () => true; 215 | 216 | const spyOnStart = jest.spyOn( 217 | controls, 218 | // @ts-ignore 219 | 'onStart', 220 | ); 221 | 222 | fireMouseEvent( 223 | MOUSE.DOWN, 224 | { 225 | clientX: 0, 226 | clientY: 0, 227 | }, 228 | ); 229 | 230 | expect(spyOnStart).not.toHaveBeenCalled(); 231 | }); 232 | 233 | it('should invoke `onStart` if the location is defined', () => { 234 | const controls = new JoystickControls( 235 | camera, 236 | scene, 237 | ); 238 | const spyOnStart = jest.spyOn( 239 | controls, 240 | // @ts-ignore 241 | 'onStart', 242 | ); 243 | 244 | fireMouseEvent( 245 | MOUSE.DOWN, 246 | { 247 | clientX: 0, 248 | clientY: 0, 249 | }, 250 | ); 251 | 252 | expect(spyOnStart).toHaveBeenCalledTimes(1); 253 | }); 254 | 255 | it('should not invoke `onMove` if the `preventAction` function returned `true`', () => { 256 | const controls = new JoystickControls( 257 | camera, 258 | scene, 259 | ); 260 | controls.preventAction = () => true; 261 | 262 | const spyOnMove = jest.spyOn( 263 | controls, 264 | // @ts-ignore 265 | 'onMove', 266 | ); 267 | 268 | fireMouseEvent( 269 | MOUSE.MOVE, 270 | { 271 | clientX: 0, 272 | clientY: 0, 273 | }, 274 | ); 275 | 276 | expect(spyOnMove).not.toHaveBeenCalled(); 277 | }); 278 | 279 | it('should invoke `onMove` if the location is defined', () => { 280 | const controls = new JoystickControls( 281 | camera, 282 | scene, 283 | ); 284 | const spyOnMove = jest.spyOn( 285 | controls, 286 | // @ts-ignore 287 | 'onMove', 288 | ); 289 | 290 | fireMouseEvent( 291 | MOUSE.MOVE, 292 | { 293 | clientX: 0, 294 | clientY: 0, 295 | }, 296 | ); 297 | 298 | expect(spyOnMove).toHaveBeenCalledTimes(1); 299 | }); 300 | 301 | it('should not invoke `onEnd` if the joystick is not attached', () => { 302 | const controls = new JoystickControls( 303 | camera, 304 | scene, 305 | ); 306 | const spyOnEnd = jest.spyOn( 307 | controls, 308 | // @ts-ignore 309 | 'onEnd', 310 | ); 311 | 312 | controls.isJoystickAttached = false; 313 | 314 | fireMouseEvent(MOUSE.UP); 315 | 316 | expect(spyOnEnd).not.toHaveBeenCalled(); 317 | }); 318 | 319 | it('should invoke `onEnd` if the joystick is attached', () => { 320 | const controls = new JoystickControls( 321 | camera, 322 | scene, 323 | ); 324 | const spyOnEnd = jest.spyOn( 325 | controls, 326 | // @ts-ignore 327 | 'onEnd', 328 | ); 329 | 330 | controls.isJoystickAttached = true; 331 | 332 | fireMouseEvent(MOUSE.UP); 333 | 334 | expect(spyOnEnd).toHaveBeenCalledTimes(1); 335 | }); 336 | }); 337 | 338 | describe('onStart', () => { 339 | const scene = new THREE.Scene(); 340 | const camera = new THREE.PerspectiveCamera(); 341 | 342 | it('should set the base anchor point to the clientX and clientY provided', () => { 343 | const controls = new JoystickControls( 344 | camera, 345 | scene, 346 | ); 347 | 348 | fireMouseEvent( 349 | MOUSE.DOWN, 350 | { 351 | clientX: 0, 352 | clientY: 128, 353 | }, 354 | ); 355 | 356 | expect(controls.baseAnchorPoint).toEqual(new THREE.Vector2(0, 128)); 357 | }); 358 | 359 | it('should set interactionHasBegan to true', () => { 360 | const controls = new JoystickControls( 361 | camera, 362 | scene, 363 | ); 364 | 365 | fireMouseEvent( 366 | MOUSE.DOWN, 367 | { 368 | clientX: 0, 369 | clientY: 128, 370 | }, 371 | ); 372 | 373 | expect(controls.interactionHasBegan).toEqual(true); 374 | }); 375 | }); 376 | 377 | describe('onMove', () => { 378 | const scene = new THREE.Scene(); 379 | const camera = new THREE.PerspectiveCamera(); 380 | 381 | it('should set the touch point to the clientX `0` and clientY `128`', () => { 382 | const controls = new JoystickControls( 383 | camera, 384 | scene, 385 | ); 386 | 387 | fireTouchEvent( 388 | TOUCH.START, 389 | { 390 | clientX: 0, 391 | clientY: 0, 392 | }, 393 | ); 394 | 395 | fireTouchEvent( 396 | TOUCH.MOVE, 397 | { 398 | clientX: 0, 399 | clientY: 128, 400 | }, 401 | ); 402 | 403 | expect(controls.touchPoint).toEqual(new THREE.Vector2(0, 128)); 404 | }); 405 | 406 | it('should invoke `attachJoystick` with the positionInScene mock vector', () => { 407 | const controls = new JoystickControls( 408 | camera, 409 | scene, 410 | ); 411 | // @ts-ignore 412 | const spyAttachJoystick = jest.spyOn(controls, 'attachJoystick'); 413 | 414 | fireTouchEvent( 415 | TOUCH.START, 416 | { 417 | clientX: 0, 418 | clientY: 0, 419 | }, 420 | ); 421 | 422 | fireTouchEvent( 423 | TOUCH.MOVE, 424 | { 425 | clientX: 0, 426 | clientY: 0, 427 | }, 428 | ); 429 | 430 | expect(spyAttachJoystick) 431 | .toHaveBeenCalledWith(new THREE.Vector2(100, 100)); 432 | }); 433 | 434 | it('should invoke `updateJoystickBallPosition` with the positionInScene', () => { 435 | const controls = new JoystickControls( 436 | camera, 437 | scene, 438 | ); 439 | const spyUpdateJoystickBallPosition = jest 440 | // @ts-ignore 441 | .spyOn(controls, 'updateJoystickBallPosition'); 442 | 443 | fireTouchEvent( 444 | TOUCH.START, 445 | { 446 | clientX: 0, 447 | clientY: 0, 448 | }, 449 | ); 450 | 451 | fireTouchEvent( 452 | TOUCH.MOVE, 453 | { 454 | clientX: 0, 455 | clientY: 0, 456 | }, 457 | ); 458 | 459 | fireTouchEvent( 460 | TOUCH.MOVE, 461 | { 462 | clientX: 0, 463 | clientY: 128, 464 | }, 465 | ); 466 | 467 | expect(spyUpdateJoystickBallPosition) 468 | .toHaveBeenCalledWith(0, 128, new THREE.Vector2(100, 100)); 469 | }); 470 | }); 471 | 472 | describe('onEnd', () => { 473 | const scene = new THREE.Scene(); 474 | const camera = new THREE.PerspectiveCamera(); 475 | const spyGetObjectByName = jest.spyOn(scene, 'getObjectByName'); 476 | 477 | it('should remove the joystick-base from the scene', () => { 478 | new JoystickControls( 479 | camera, 480 | scene, 481 | ); 482 | 483 | fireTouchEvent( 484 | TOUCH.START, 485 | { 486 | clientX: 0, 487 | clientY: 0, 488 | }, 489 | ); 490 | 491 | fireTouchEvent( 492 | TOUCH.MOVE, 493 | { 494 | clientX: 0, 495 | clientY: 0, 496 | }, 497 | ); 498 | 499 | fireTouchEvent( 500 | TOUCH.END, 501 | { 502 | clientX: 0, 503 | clientY: 0, 504 | }, 505 | ); 506 | 507 | expect(spyGetObjectByName).toHaveBeenCalledWith('joystick-base'); 508 | expect(scene.getObjectByName('joystick-base')).toBeUndefined(); 509 | }); 510 | 511 | it('should remove the joystick-ball from the scene', () => { 512 | new JoystickControls( 513 | camera, 514 | scene, 515 | ); 516 | 517 | fireTouchEvent( 518 | TOUCH.START, 519 | { 520 | clientX: 0, 521 | clientY: 0, 522 | }, 523 | ); 524 | 525 | fireTouchEvent( 526 | TOUCH.MOVE, 527 | { 528 | clientX: 0, 529 | clientY: 0, 530 | }, 531 | ); 532 | 533 | fireTouchEvent( 534 | TOUCH.END, 535 | { 536 | clientX: 0, 537 | clientY: 0, 538 | }, 539 | ); 540 | 541 | expect(spyGetObjectByName).toHaveBeenLastCalledWith('joystick-ball'); 542 | expect(scene.getObjectByName('joystick-ball')).toBeUndefined(); 543 | }); 544 | 545 | it('should set isJoystickAttached to false', () => { 546 | const controls = new JoystickControls( 547 | camera, 548 | scene, 549 | ); 550 | controls.isJoystickAttached = true; 551 | controls.interactionHasBegan = true; 552 | 553 | fireTouchEvent( 554 | TOUCH.END, 555 | { 556 | clientX: 0, 557 | clientY: 0, 558 | }, 559 | ); 560 | 561 | expect(controls.isJoystickAttached).toEqual(false); 562 | }); 563 | 564 | it('should set interactionHasBegan to false', () => { 565 | const controls = new JoystickControls( 566 | camera, 567 | scene, 568 | ); 569 | controls.isJoystickAttached = true; 570 | controls.interactionHasBegan = true; 571 | 572 | fireTouchEvent( 573 | TOUCH.END, 574 | { 575 | clientX: 0, 576 | clientY: 0, 577 | }, 578 | ); 579 | 580 | expect(controls.interactionHasBegan).toEqual(false); 581 | }); 582 | }); 583 | 584 | describe('attachJoystickUI', () => { 585 | const scene = new THREE.Scene(); 586 | const camera = new THREE.PerspectiveCamera(); 587 | 588 | it('should set the position to the mockPosition', () => { 589 | const controls = new JoystickControls( 590 | camera, 591 | scene, 592 | ); 593 | const mockColor = 0x111111; 594 | const mockSize = 100; 595 | const mockPosition = new THREE.Vector3(1, 2, 3); 596 | const mockName = 'test-ui'; 597 | // @ts-ignore 598 | controls.attachJoystickUI( 599 | mockName, 600 | mockPosition, 601 | mockColor, 602 | mockSize, 603 | ); 604 | // @ts-ignore 605 | const sceneUIObject = scene.getObjectByName(mockName) as any; 606 | 607 | sceneUIObject.uuid = 'stripped-for-snapshot-test'; 608 | sceneUIObject.geometry.uuid = 'stripped-for-snapshot-test'; 609 | sceneUIObject.material.uuid = 'stripped-for-snapshot-test'; 610 | sceneUIObject.parent.uuid = 'stripped-for-snapshot-test'; 611 | 612 | expect(sceneUIObject).toMatchSnapshot(); 613 | }); 614 | }); 615 | 616 | describe('attachJoystick', () => { 617 | it('should attach `joystick-base` to the scene', () => { 618 | const scene = new THREE.Scene(); 619 | const camera = new THREE.PerspectiveCamera(); 620 | 621 | new JoystickControls( 622 | camera, 623 | scene, 624 | ); 625 | 626 | fireTouchEvent( 627 | TOUCH.START, 628 | { 629 | clientX: 0, 630 | clientY: 0, 631 | }, 632 | ); 633 | 634 | fireTouchEvent( 635 | TOUCH.MOVE, 636 | { 637 | clientX: 0, 638 | clientY: 128, 639 | }, 640 | ); 641 | 642 | expect(scene.getObjectByName('joystick-base')?.name) 643 | .toEqual('joystick-base'); 644 | }); 645 | 646 | it('should attach joystick-ball to the scene', () => { 647 | const scene = new THREE.Scene(); 648 | const camera = new THREE.PerspectiveCamera(); 649 | 650 | new JoystickControls( 651 | camera, 652 | scene, 653 | ); 654 | 655 | fireTouchEvent( 656 | TOUCH.START, 657 | { 658 | clientX: 0, 659 | clientY: 0, 660 | }, 661 | ); 662 | 663 | fireTouchEvent( 664 | TOUCH.MOVE, 665 | { 666 | clientX: 0, 667 | clientY: 128, 668 | }, 669 | ); 670 | 671 | expect(scene.getObjectByName('joystick-ball')?.name) 672 | .toEqual('joystick-ball'); 673 | }); 674 | 675 | it('should set isJoystickAttached to true', () => { 676 | const scene = new THREE.Scene(); 677 | const camera = new THREE.PerspectiveCamera(); 678 | const controls = new JoystickControls( 679 | camera, 680 | scene, 681 | ); 682 | 683 | expect(controls.isJoystickAttached).toEqual(false); 684 | 685 | fireTouchEvent( 686 | TOUCH.START, 687 | { 688 | clientX: 0, 689 | clientY: 0, 690 | }, 691 | ); 692 | 693 | fireTouchEvent( 694 | TOUCH.MOVE, 695 | { 696 | clientX: 0, 697 | clientY: 128, 698 | }, 699 | ); 700 | 701 | expect(controls.isJoystickAttached).toEqual(true); 702 | }); 703 | }); 704 | 705 | describe('getJoystickBallPosition', () => { 706 | it('should return the positionInScene if the touch was in side the joystick base', () => { 707 | const scene = new THREE.Scene(); 708 | const camera = new THREE.PerspectiveCamera(); 709 | const controls = new JoystickControls( 710 | camera, 711 | scene, 712 | ); 713 | 714 | fireTouchEvent( 715 | TOUCH.START, 716 | { 717 | clientX: 0, 718 | clientY: 0, 719 | }, 720 | ); 721 | 722 | expect( 723 | // @ts-ignore 724 | controls.getJoystickBallPosition( 725 | 0, 726 | 0, 727 | new THREE.Vector3(10, 10, 0), 728 | ), 729 | ).toEqual(new THREE.Vector3(10, 10, 0)); 730 | }); 731 | }); 732 | 733 | it('should return the positionInScene if the touch was in side the joystick base', () => { 734 | const scene = new THREE.Scene(); 735 | const camera = new THREE.PerspectiveCamera(); 736 | const controls = new JoystickControls( 737 | camera, 738 | scene, 739 | ); 740 | 741 | fireTouchEvent( 742 | TOUCH.START, 743 | { 744 | clientX: 0, 745 | clientY: 0, 746 | }, 747 | ); 748 | 749 | fireTouchEvent( 750 | TOUCH.MOVE, 751 | { 752 | clientX: 1000, 753 | clientY: 1000, 754 | }, 755 | ); 756 | 757 | expect( 758 | // @ts-ignore 759 | controls.getJoystickBallPosition( 760 | 1000, 761 | 1000, 762 | new THREE.Vector3(1, 1, 0), 763 | ), 764 | ).toEqual(new THREE.Vector3(100.70710678118655, 99.29289321881345, 0)); 765 | }); 766 | 767 | describe('updateJoystickBallPosition', () => { 768 | it('should update the joystickBall position', () => { 769 | const scene = new THREE.Scene(); 770 | const camera = new THREE.PerspectiveCamera(); 771 | const controls = new JoystickControls( 772 | camera, 773 | scene, 774 | ); 775 | 776 | fireTouchEvent( 777 | TOUCH.START, 778 | { 779 | clientX: 0, 780 | clientY: 0, 781 | }, 782 | ); 783 | 784 | fireTouchEvent( 785 | TOUCH.MOVE, 786 | { 787 | clientX: 1000, 788 | clientY: 1000, 789 | }, 790 | ); 791 | 792 | // @ts-ignore 793 | controls.updateJoystickBallPosition( 794 | 1, 795 | 1, 796 | new THREE.Vector3(1, 1, 0), 797 | ); 798 | 799 | expect(scene.getObjectByName('joystick-ball')?.position) 800 | .toEqual(new THREE.Vector3(1, 1, 0)); 801 | }); 802 | 803 | it('should not update the joystickBall position if the joyStickBall does not exist', () => { 804 | const scene = new THREE.Scene(); 805 | const camera = new THREE.PerspectiveCamera(); 806 | const controls = new JoystickControls( 807 | camera, 808 | scene, 809 | ); 810 | 811 | fireTouchEvent( 812 | TOUCH.START, 813 | { 814 | clientX: 0, 815 | clientY: 0, 816 | }, 817 | ); 818 | 819 | fireTouchEvent( 820 | TOUCH.MOVE, 821 | { 822 | clientX: 1000, 823 | clientY: 1000, 824 | }, 825 | ); 826 | 827 | scene.getObjectByName('joystick-ball')?.removeFromParent(); 828 | 829 | // @ts-ignore 830 | controls.updateJoystickBallPosition( 831 | 1, 832 | 1, 833 | new THREE.Vector3(1, 1, 0), 834 | ); 835 | 836 | expect(scene.getObjectByName('joystick-ball')?.position) 837 | .toBeUndefined(); 838 | }); 839 | }); 840 | 841 | describe('getJoystickMovement', () => { 842 | it('should return the distanced moved', function () { 843 | const scene = new THREE.Scene(); 844 | const camera = new THREE.PerspectiveCamera(); 845 | const controls = new JoystickControls( 846 | camera, 847 | scene, 848 | ); 849 | 850 | fireTouchEvent( 851 | TOUCH.START, 852 | { 853 | clientX: 88, 854 | clientY: 188, 855 | }, 856 | ); 857 | 858 | fireTouchEvent( 859 | TOUCH.MOVE, 860 | { 861 | clientX: 1000, 862 | clientY: 1000, 863 | }, 864 | ); 865 | 866 | // @ts-ignore 867 | expect(controls.getJoystickMovement()).toEqual({ 868 | moveX: 912, 869 | moveY: 812, 870 | }); 871 | }); 872 | }); 873 | 874 | describe('destroy', () => { 875 | it('should remove all event listeners', function () { 876 | const scene = new THREE.Scene(); 877 | const camera = new THREE.PerspectiveCamera(); 878 | const controls = new JoystickControls( 879 | camera, 880 | scene, 881 | ); 882 | const spyRemoveEventListener = jest.spyOn(window, 'removeEventListener'); 883 | 884 | controls.destroy(); 885 | 886 | expect(spyRemoveEventListener.mock.calls).toEqual([ 887 | [ 888 | // @ts-ignore 889 | 'touchstart', controls.handleTouchStart, 890 | ], [ 891 | // @ts-ignore 892 | 'touchmove', controls.handleTouchMove, 893 | ], [ 894 | // @ts-ignore 895 | 'touchend', controls.handleEventEnd, 896 | ], [ 897 | // @ts-ignore 898 | 'mousedown', controls.handleMouseDown, 899 | ], [ 900 | // @ts-ignore 901 | 'mousemove', controls.handleMouseMove, 902 | ], [ 903 | // @ts-ignore 904 | 'mouseup', controls.handleEventEnd, 905 | ], 906 | ]); 907 | }); 908 | }); 909 | 910 | describe('create', () => { 911 | it('should add all event listeners', function () { 912 | const spyAddEventListener = jest.spyOn(window, 'addEventListener'); 913 | const scene = new THREE.Scene(); 914 | const camera = new THREE.PerspectiveCamera(); 915 | const controls = new JoystickControls( 916 | camera, 917 | scene, 918 | ); 919 | 920 | expect(spyAddEventListener.mock.calls).toEqual([ 921 | [ 922 | // @ts-ignore 923 | 'touchstart', controls.handleTouchStart, 924 | ], [ 925 | // @ts-ignore 926 | 'touchmove', controls.handleTouchMove, 927 | ], [ 928 | // @ts-ignore 929 | 'touchend', controls.handleEventEnd, 930 | ], [ 931 | // @ts-ignore 932 | 'mousedown', controls.handleMouseDown, 933 | ], [ 934 | // @ts-ignore 935 | 'mousemove', controls.handleMouseMove, 936 | ], [ 937 | // @ts-ignore 938 | 'mouseup', controls.handleEventEnd, 939 | ], 940 | ]); 941 | }); 942 | }); 943 | 944 | describe('update', () => { 945 | it('should invoke the callback with the movement info', function () { 946 | const scene = new THREE.Scene(); 947 | const camera = new THREE.PerspectiveCamera(); 948 | const controls = new JoystickControls( 949 | camera, 950 | scene, 951 | ); 952 | const mockCallBack = jest.fn(); 953 | 954 | fireTouchEvent( 955 | TOUCH.START, 956 | { 957 | clientX: 188, 958 | clientY: 188, 959 | }, 960 | ); 961 | 962 | fireTouchEvent( 963 | TOUCH.MOVE, 964 | { 965 | clientX: 1000, 966 | clientY: 1000, 967 | }, 968 | ); 969 | 970 | controls.update(mockCallBack); 971 | 972 | expect(mockCallBack).toHaveBeenCalledWith({ 973 | moveX: 812, 974 | moveY: 812, 975 | }); 976 | }); 977 | 978 | it('should not throw error if there is no callback', function () { 979 | const scene = new THREE.Scene(); 980 | const camera = new THREE.PerspectiveCamera(); 981 | const controls = new JoystickControls( 982 | camera, 983 | scene, 984 | ); 985 | 986 | fireTouchEvent( 987 | TOUCH.START, 988 | { 989 | clientX: 188, 990 | clientY: 188, 991 | }, 992 | ); 993 | 994 | fireTouchEvent( 995 | TOUCH.MOVE, 996 | { 997 | clientX: 1000, 998 | clientY: 1000, 999 | }, 1000 | ); 1001 | 1002 | 1003 | 1004 | expect(() => { 1005 | controls.update(); 1006 | }).not.toThrowError(); 1007 | }); 1008 | }); 1009 | }); 1010 | -------------------------------------------------------------------------------- /src/__tests__/RotationJoystickControls.test.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import RotationJoystickControls from '../RotationJoystickControls'; 3 | 4 | const mockSwipe = (clientX = 88, clientY = 128) => { 5 | const touchStart = new TouchEvent('touchstart', {}); 6 | touchStart.touches.item = () => ({ 7 | clientX: 0, 8 | clientY: 0, 9 | } as Touch); 10 | const touchMove = new TouchEvent('touchmove', {}); 11 | touchMove.touches.item = () => ({ 12 | clientX, 13 | clientY, 14 | } as Touch); 15 | window.dispatchEvent(touchStart); 16 | window.dispatchEvent(touchMove); 17 | }; 18 | 19 | describe('RotationJoystickControls', () => { 20 | const scene = new THREE.Scene(); 21 | const camera = new THREE.PerspectiveCamera(); 22 | const target = new THREE.Mesh(); 23 | 24 | beforeEach(() => { 25 | target.quaternion.set(0, 0, 0, 0); 26 | }); 27 | 28 | it('should rotate the vertical movement axis', () => { 29 | const controls = new RotationJoystickControls( 30 | camera, 31 | scene, 32 | target, 33 | ); 34 | const spyOnRotateVerticalMovement = jest.spyOn( 35 | controls, 36 | // @ts-ignore 37 | 'rotateVerticalMovement', 38 | ); 39 | 40 | mockSwipe(); 41 | 42 | controls.update(); 43 | 44 | expect(spyOnRotateVerticalMovement).toHaveBeenCalledWith(0.128); 45 | }); 46 | 47 | it('should not rotate the vertical movement axis', () => { 48 | const controls = new RotationJoystickControls( 49 | camera, 50 | scene, 51 | target, 52 | ); 53 | const spyOnRotateVerticalMovement = jest.spyOn( 54 | controls, 55 | // @ts-ignore 56 | 'rotateVerticalMovement', 57 | ); 58 | 59 | controls.update(); 60 | 61 | expect(spyOnRotateVerticalMovement).not.toHaveBeenCalled(); 62 | }); 63 | 64 | it('should invoke rotateHorizontalMovement with the angle 0.128', () => { 65 | const controls = new RotationJoystickControls( 66 | camera, 67 | scene, 68 | target, 69 | ); 70 | const spyOnRotateHorizontalMovement = jest.spyOn( 71 | controls, 72 | // @ts-ignore 73 | 'rotateHorizontalMovement', 74 | ); 75 | 76 | mockSwipe(); 77 | 78 | controls.update(); 79 | 80 | expect(spyOnRotateHorizontalMovement).toHaveBeenCalledWith(0.088); 81 | }); 82 | 83 | it('should not rotate the horizontal movement axis', () => { 84 | const controls = new RotationJoystickControls( 85 | camera, 86 | scene, 87 | target, 88 | ); 89 | const spyOnRotateHorizontalMovement = jest.spyOn( 90 | controls, 91 | // @ts-ignore 92 | 'rotateHorizontalMovement', 93 | ); 94 | 95 | controls.update(); 96 | 97 | expect(spyOnRotateHorizontalMovement).not.toHaveBeenCalled(); 98 | }); 99 | 100 | it('should set the reference quarternion fromthe axis angle', () => { 101 | const controls = new RotationJoystickControls( 102 | camera, 103 | scene, 104 | target, 105 | ); 106 | 107 | mockSwipe(); 108 | 109 | controls.update(); 110 | 111 | expect(controls.quaternion).toEqual( 112 | new THREE.Quaternion( 113 | 0, 114 | 0.043985804040905185, 115 | 0, 116 | 0.9990321561605888, 117 | ), 118 | ); 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/JoystickControls.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`JoystickControls attachJoystickUI should set the position to the mockPosition 1`] = ` 4 | Object { 5 | "geometries": Array [ 6 | Object { 7 | "radius": 100, 8 | "segments": 72, 9 | "thetaLength": 6.283185307179586, 10 | "thetaStart": 0, 11 | "type": "CircleGeometry", 12 | "uuid": "stripped-for-snapshot-test", 13 | }, 14 | ], 15 | "materials": Array [ 16 | Object { 17 | "color": 1118481, 18 | "colorWrite": true, 19 | "depthFunc": 3, 20 | "depthTest": false, 21 | "depthWrite": true, 22 | "emissive": 0, 23 | "opacity": 0.5, 24 | "reflectivity": 1, 25 | "refractionRatio": 0.98, 26 | "stencilFail": 7680, 27 | "stencilFunc": 519, 28 | "stencilFuncMask": 255, 29 | "stencilRef": 0, 30 | "stencilWrite": false, 31 | "stencilWriteMask": 255, 32 | "stencilZFail": 7680, 33 | "stencilZPass": 7680, 34 | "transparent": true, 35 | "type": "MeshLambertMaterial", 36 | "uuid": "stripped-for-snapshot-test", 37 | }, 38 | ], 39 | "metadata": Object { 40 | "generator": "Object3D.toJSON", 41 | "type": "Object", 42 | "version": 4.5, 43 | }, 44 | "object": Object { 45 | "geometry": "stripped-for-snapshot-test", 46 | "layers": 1, 47 | "material": "stripped-for-snapshot-test", 48 | "matrix": Array [ 49 | 1, 50 | 0, 51 | 0, 52 | 0, 53 | 0, 54 | 1, 55 | 0, 56 | 0, 57 | 0, 58 | 0, 59 | 1, 60 | 0, 61 | 0, 62 | 0, 63 | 0, 64 | 1, 65 | ], 66 | "name": "test-ui", 67 | "renderOrder": 1, 68 | "type": "Mesh", 69 | "uuid": "stripped-for-snapshot-test", 70 | }, 71 | } 72 | `; 73 | -------------------------------------------------------------------------------- /src/helpers/__tests__/degreesToRadians.ts: -------------------------------------------------------------------------------- 1 | import degreesToRadians from '../degreesToRadians'; 2 | 3 | describe('degreesToRadians', () => { 4 | test.each([ 5 | [0, 0], 6 | [45, 0.7853981633974483], 7 | [90, 1.5707963267948966], 8 | [135, 2.356194490192345], 9 | [180, 3.141592653589793], 10 | [225, 3.9269908169872414], 11 | [270, 4.71238898038469], 12 | [315, 5.497787143782138], 13 | [360, 6.283185307179586], 14 | ])('should convert %d° to %frad', (degrees, radians) => { 15 | expect(degreesToRadians(degrees)).toEqual(radians); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/helpers/__tests__/getPositionInScene.test.ts: -------------------------------------------------------------------------------- 1 | import getPositionInScene from '../getPositionInScene'; 2 | import { PerspectiveCamera, Vector3 } from 'three'; 3 | 4 | describe('getPositionInScene', () => { 5 | beforeEach(() => { 6 | Object.defineProperty(window, 'innerHeight', { 7 | writable: true, 8 | configurable: true, 9 | value: 939, 10 | }); 11 | Object.defineProperty(window, 'innerWidth', { 12 | writable: true, 13 | configurable: true, 14 | value: 1680, 15 | }); 16 | }); 17 | 18 | it('should return convert the given coordinates into the position in the scene', function () { 19 | const mockClientX = 100; 20 | const mockClientY = 100; 21 | const mockCamera = new PerspectiveCamera(); 22 | 23 | expect( 24 | getPositionInScene( 25 | mockClientX, 26 | mockClientY, 27 | mockCamera, 28 | ), 29 | ).toEqual( 30 | new Vector3( 31 | -3.5981622399601454, 32 | 3.214453547588953, 33 | -8.759024882104045, 34 | ), 35 | ); 36 | }); 37 | 38 | it('should return convert the given coordinates into the position in the scene using a custom scale factor', function () { 39 | const mockClientX = 100; 40 | const mockClientY = 100; 41 | const mockCamera = new PerspectiveCamera(); 42 | const mockScaleFactor = 100; 43 | 44 | expect( 45 | getPositionInScene( 46 | mockClientX, 47 | mockClientY, 48 | mockCamera, 49 | mockScaleFactor, 50 | ), 51 | ).toEqual( 52 | new Vector3( 53 | -35.981622399601454, 54 | 32.144535475889526, 55 | -87.59024882104045, 56 | ), 57 | ); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/helpers/__tests__/isTouchOutOfBounds.test.ts: -------------------------------------------------------------------------------- 1 | import isTouchOutOfBounds from '../isTouchOutOfBounds'; 2 | import { Vector2 } from 'three'; 3 | 4 | describe('isTouchOutOfBounds', () => { 5 | test.each( 6 | [ 7 | [ 8 | 'return false if the touched point is in the NE quadrant and not out of bounds', 9 | 7, 10 | 7, 11 | false, 12 | ], 13 | [ 14 | 'return true if the touched point is in the NE quadrant and out of bounds', 15 | 11, 16 | 11, 17 | true, 18 | ], 19 | [ 20 | 'return false if the touched point is in the NW quadrant and not out of bounds', 21 | -7, 22 | 7, 23 | false, 24 | ], 25 | [ 26 | 'return true if the touched point is in the NW quadrant and out of bounds', 27 | -11, 28 | 11, 29 | true, 30 | ], 31 | [ 32 | 'return false if the touched point is in the SE quadrant and not out of bounds', 33 | 7, 34 | -7, 35 | false, 36 | ], 37 | [ 38 | 'return true if the touched point is in the SE quadrant and out of bounds', 39 | 11, 40 | -11, 41 | true, 42 | ], 43 | [ 44 | 'return false if the touched point is in the SW quadrant and not out of bounds', 45 | -7, 46 | -7, 47 | false, 48 | ], 49 | [ 50 | 'return true if the touched point is in the SW quadrant and out of bounds', 51 | -11, 52 | -11, 53 | true, 54 | ], 55 | ], 56 | )( 57 | 'should %p', 58 | (_description, clientX, clientY, result) => { 59 | const mockBaseAnchorPoint = new Vector2(0, 0); 60 | const mockPerimeterSize = 10; 61 | 62 | expect(isTouchOutOfBounds( 63 | clientX, 64 | clientY, 65 | mockBaseAnchorPoint, 66 | mockPerimeterSize, 67 | )).toEqual(result); 68 | }, 69 | ); 70 | }); 71 | -------------------------------------------------------------------------------- /src/helpers/__tests__/userSwipedMoreThan.test.ts: -------------------------------------------------------------------------------- 1 | import userSwipedMoreThan from '../userSwipedMoreThan'; 2 | import { Vector2 } from 'three'; 3 | 4 | describe('userSwipedMoreThan', () => { 5 | const mockTouchStart = new Vector2(0, 0); 6 | const mockMinDistance = 100; 7 | 8 | it('should return true if the user swiped more than the minDistance in the x axis', function () { 9 | const mockClientX = 101; 10 | const mockClientY = 0; 11 | 12 | expect( 13 | userSwipedMoreThan( 14 | mockClientX, 15 | mockClientY, 16 | mockTouchStart, 17 | mockMinDistance 18 | ) 19 | ).toEqual(true); 20 | }); 21 | 22 | it('should return false if the user did not swipe more than the minDistance in the x axis', function () { 23 | const mockClientX = 100; 24 | const mockClientY = 0; 25 | 26 | expect( 27 | userSwipedMoreThan( 28 | mockClientX, 29 | mockClientY, 30 | mockTouchStart, 31 | mockMinDistance 32 | ) 33 | ).toEqual(false); 34 | }); 35 | 36 | it('should return true if the user swiped more than the minDistance in the y axis', function () { 37 | const mockClientX = 0; 38 | const mockClientY = 101; 39 | 40 | expect( 41 | userSwipedMoreThan( 42 | mockClientX, 43 | mockClientY, 44 | mockTouchStart, 45 | mockMinDistance 46 | ) 47 | ).toEqual(true); 48 | }); 49 | 50 | it('should return false if the user did not swipe more than the minDistance in the y axis', function () { 51 | const mockClientX = 0; 52 | const mockClientY = 100; 53 | 54 | expect( 55 | userSwipedMoreThan( 56 | mockClientX, 57 | mockClientY, 58 | mockTouchStart, 59 | mockMinDistance 60 | ) 61 | ).toEqual(false); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/helpers/degreesToRadians.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Helper for converting degrees to radians 3 | * 4 | * @param degrees 5 | */ 6 | const degreesToRadians = (degrees: number): number => { 7 | return degrees * (Math.PI / 180); 8 | }; 9 | 10 | export default degreesToRadians; 11 | -------------------------------------------------------------------------------- /src/helpers/getPositionInScene.ts: -------------------------------------------------------------------------------- 1 | import { PerspectiveCamera, Vector3 } from 'three'; 2 | 3 | /** 4 | * Takes a touch point and converts it to a vector that represents that 5 | * position in the 3d world 6 | */ 7 | const getPositionInScene = ( 8 | clientX: number, 9 | clientY: number, 10 | camera: PerspectiveCamera, 11 | scale = 10, 12 | ): Vector3 => { 13 | const relativeX = (clientX / window.innerWidth) * 2 - 1; 14 | const relativeY = -(clientY / window.innerHeight) * 2 + 1; 15 | 16 | const inSceneTouchVector = new Vector3(relativeX, relativeY, 0) 17 | .unproject(camera) 18 | .sub(camera.position) 19 | .normalize() 20 | .multiplyScalar(scale); 21 | 22 | return camera 23 | .position 24 | .clone() 25 | .add(inSceneTouchVector); 26 | }; 27 | 28 | export default getPositionInScene; 29 | -------------------------------------------------------------------------------- /src/helpers/isTouchOutOfBounds.ts: -------------------------------------------------------------------------------- 1 | import { Vector2 } from 'three'; 2 | 3 | /** 4 | * Used for checking if the touch point is within a perimeter that 5 | * is defined by the param perimeterSize. 6 | * 7 | * Uses Pythagoras' Theorem to calculate the distance from the origin 8 | */ 9 | const isTouchOutOfBounds = ( 10 | clientX: number, 11 | clientY: number, 12 | origin: Vector2, 13 | perimeterSize: number, 14 | ): boolean => { 15 | const xDelta = (clientX - origin.x); 16 | const yDelta = (clientY - origin.y); 17 | const xSquared = Math.pow(xDelta, 2); 18 | const ySquared = Math.pow(yDelta, 2); 19 | const distanceFromTheOrigin = Math.sqrt(xSquared + ySquared); 20 | 21 | return (perimeterSize <= distanceFromTheOrigin); 22 | }; 23 | 24 | export default isTouchOutOfBounds; 25 | -------------------------------------------------------------------------------- /src/helpers/userSwipedMoreThan.ts: -------------------------------------------------------------------------------- 1 | import { Vector2 } from 'three'; 2 | 3 | /** 4 | * Currently unused, but this function can determine if a user 5 | * has swiped more than a set distance 6 | * 7 | * @param clientX 8 | * @param clientY 9 | * @param touchStart 10 | * @param minDistance 11 | */ 12 | const userSwipedMoreThan = ( 13 | clientX: number, 14 | clientY: number, 15 | touchStart: Vector2, 16 | minDistance: number, 17 | ): boolean => { 18 | const xDistance = Math.abs(touchStart.x - clientX); 19 | const yDistance = Math.abs(touchStart.y - clientY); 20 | 21 | return ( 22 | (xDistance > minDistance) || 23 | (yDistance > minDistance) 24 | ); 25 | }; 26 | 27 | export default userSwipedMoreThan; 28 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { JoystickControls } from './JoystickControls'; 2 | import { RotationJoystickControls } from './RotationJoystickControls'; 3 | 4 | export { 5 | JoystickControls, 6 | RotationJoystickControls, 7 | }; 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */ 8 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./dist/index.d.ts", /* Concatenate and emit output to single file. */ 17 | "outDir": "./dist", /* Redirect output structure to the directory. */ 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | "noEmit": false, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true, /* Enable all strict type-checking options. */ 29 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 43 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an 'override' modifier. */ 44 | // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ 45 | 46 | /* Module Resolution Options */ 47 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 48 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 49 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 50 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 51 | // "typeRoots": [], /* List of folders to include type definitions from. */ 52 | // "types": [], /* Type declaration files to be included in compilation. */ 53 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 54 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 55 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 56 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 57 | 58 | /* Source Map Options */ 59 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 60 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 61 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 62 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 63 | 64 | /* Experimental Options */ 65 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 66 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 67 | 68 | /* Advanced Options */ 69 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 70 | "forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */ 71 | }, 72 | "include": [ 73 | "./src", 74 | "./typings" 75 | ], 76 | "exclude": [ 77 | "node_modules", 78 | "**/__tests__/*" 79 | ] 80 | } 81 | -------------------------------------------------------------------------------- /typings/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'three-trackballcontrols'; 2 | 3 | type TMovement = { 4 | moveX: number; 5 | moveY: number; 6 | } 7 | -------------------------------------------------------------------------------- /webpack.common.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import webpack from 'webpack'; 3 | import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin'; 4 | import ESLintPlugin from 'eslint-webpack-plugin'; 5 | import { CleanWebpackPlugin } from 'clean-webpack-plugin'; 6 | import MiniCssExtractPlugin from 'mini-css-extract-plugin'; 7 | import CopyWebpackPlugin from 'copy-webpack-plugin'; 8 | import HtmlWebpackPlugin from 'html-webpack-plugin'; 9 | 10 | const libConfig: webpack.Configuration = { 11 | entry: { 12 | 'index': './src/index.ts', 13 | }, 14 | output: { 15 | path: path.resolve(__dirname, 'dist'), 16 | filename: '[name].js', 17 | library: { 18 | name: 'threeJoystick', 19 | type: 'umd', 20 | }, 21 | publicPath: '/', 22 | }, 23 | externals: { 24 | three: 'three', 25 | }, 26 | performance: { 27 | maxEntrypointSize: 1000000, 28 | maxAssetSize: 1000000, 29 | }, 30 | module: { 31 | rules: [ 32 | { 33 | test: /\.(ts|js)x?$/i, 34 | exclude: /node_modules/, 35 | use: { 36 | loader: 'babel-loader', 37 | options: { 38 | presets: [ 39 | '@babel/preset-env', 40 | '@babel/preset-typescript', 41 | ], 42 | }, 43 | }, 44 | }, 45 | ], 46 | }, 47 | resolve: { 48 | extensions: ['*', '.tsx', '.ts', '.js'], 49 | }, 50 | plugins: [ 51 | new ForkTsCheckerWebpackPlugin({ 52 | async: false, 53 | }), 54 | new ESLintPlugin({ 55 | extensions: ['js', 'jsx', 'ts', 'tsx'], 56 | }), 57 | new CleanWebpackPlugin(), 58 | ], 59 | }; 60 | 61 | const exampleConfig: webpack.Configuration = { 62 | entry: { 63 | 'examples/RotatingTargetExample/scene': './examples/RotatingTargetExample/scene.ts', 64 | 'examples/BasicExample/scene': './examples/BasicExample/scene.ts', 65 | }, 66 | output: { 67 | path: path.resolve(__dirname, 'docs'), 68 | filename: '[name].js', 69 | publicPath: '/three-joystick/', 70 | }, 71 | performance: { 72 | maxEntrypointSize: 1000000, 73 | maxAssetSize: 1000000, 74 | }, 75 | module: { 76 | rules: [ 77 | { 78 | test: /\.(eot|otf|ttf|woff|woff2)(\?.*)?$/, 79 | type: 'asset/resource', 80 | generator: { 81 | filename: 'fonts/[name][ext]', 82 | }, 83 | }, 84 | { 85 | test: /\.(ico|jpg|jpeg|png|gif|webp|svg)(\?.*)?$/, 86 | type: 'asset/resource', 87 | generator: { 88 | filename: 'images/[name][ext]', 89 | }, 90 | }, 91 | { 92 | test: /\.(s(a|c)ss)$/, 93 | use: [ 94 | MiniCssExtractPlugin.loader, 95 | 'css-loader', 96 | 'sass-loader', 97 | ], 98 | }, 99 | { 100 | test: /\.(ts|js)x?$/i, 101 | exclude: /node_modules/, 102 | use: { 103 | loader: 'babel-loader', 104 | options: { 105 | presets: [ 106 | '@babel/preset-env', 107 | '@babel/preset-typescript', 108 | ], 109 | }, 110 | }, 111 | }, 112 | ], 113 | }, 114 | resolve: { 115 | extensions: ['*', '.tsx', '.ts', '.js'], 116 | }, 117 | plugins: [ 118 | new CleanWebpackPlugin(), 119 | new ForkTsCheckerWebpackPlugin({ 120 | async: false, 121 | }), 122 | new MiniCssExtractPlugin({ 123 | filename: '[name].css', 124 | }), 125 | new ESLintPlugin({ 126 | extensions: ['js', 'jsx', 'ts', 'tsx'], 127 | }), 128 | new CopyWebpackPlugin({ 129 | patterns: [ 130 | { 131 | from: 'examples/RotatingTargetExample/images', 132 | to: 'examples/RotatingTargetExample/images', 133 | }, 134 | ], 135 | }), 136 | new HtmlWebpackPlugin({ 137 | filename: 'examples/RotatingTargetExample/index.html', 138 | template: 'examples/RotatingTargetExample/index.html', 139 | chunks: ['examples/RotatingTargetExample/scene'], 140 | }), 141 | new HtmlWebpackPlugin({ 142 | filename: 'examples/BasicExample/index.html', 143 | template: 'examples/BasicExample/index.html', 144 | chunks: ['examples/BasicExample/scene'], 145 | }), 146 | ], 147 | }; 148 | 149 | export default { 150 | libConfig, 151 | exampleConfig, 152 | }; 153 | -------------------------------------------------------------------------------- /webpack.dev.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import path from "path"; 3 | import { merge } from 'webpack-merge'; 4 | import common from './webpack.common'; 5 | 6 | export default [ 7 | merge( 8 | common.libConfig, 9 | { 10 | mode: 'development', 11 | } 12 | ), 13 | merge( 14 | common.exampleConfig, 15 | { 16 | mode: 'development', 17 | devtool: 'cheap-module-source-map', 18 | devServer: { 19 | static: { 20 | directory: path.join(__dirname, 'docs'), 21 | }, 22 | compress: true, 23 | port: 8080, 24 | }, 25 | } 26 | ), 27 | ]; 28 | -------------------------------------------------------------------------------- /webpack.prod.ts: -------------------------------------------------------------------------------- 1 | import { merge } from 'webpack-merge'; 2 | import common from './webpack.common'; 3 | 4 | export default [ 5 | merge( 6 | common.libConfig, 7 | { 8 | mode: 'production', 9 | devtool: 'source-map', 10 | }, 11 | ), 12 | merge( 13 | common.exampleConfig, 14 | { 15 | mode: 'production', 16 | devtool: 'source-map', 17 | }, 18 | ), 19 | ]; 20 | --------------------------------------------------------------------------------