├── .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 |
--------------------------------------------------------------------------------