├── .eslintignore ├── .eslintignore ├── CONTRIBUTING.md ├── .gitignore ├── src ├── index.tsx ├── enums │ └── shape.enum.ts ├── shapes │ ├── shape.factory.ts │ └── shape.bounds.factory.ts ├── stories.css ├── Joystick.test.tsx ├── Joystick.stories.tsx └── Joystick.tsx ├── .storybook ├── preview.js └── main.js ├── semantic-release-templates ├── commit-template.hbs └── default-template.hbs ├── .eslintrc ├── tslint.json ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── release.yml ├── tsconfig.json ├── LICENSE ├── .releaserc.js ├── package.json ├── README.md └── CODE_OF_CONDUCT.md /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build -------------------------------------------------------------------------------- /.eslintignore : -------------------------------------------------------------------------------- 1 | node_modules 2 | build -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Use Gitmoji for commits, other than that, fire away! 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | build 4 | .cache 5 | yarn-error.log 6 | .DS_Store 7 | coverage -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | export { Joystick } from "./Joystick"; 2 | export { JoystickShape } from './enums/shape.enum'; -------------------------------------------------------------------------------- /src/enums/shape.enum.ts: -------------------------------------------------------------------------------- 1 | export enum JoystickShape { 2 | Circle = 'circle', 3 | Square = 'square', 4 | AxisY = 'axisY', 5 | AxisX = 'axisX', 6 | } -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | export const parameters = { 2 | actions: { argTypesRegex: "^on[A-Z].*" }, 3 | controls: { 4 | matchers: { 5 | color: /(background|color)$/i, 6 | date: /Date$/, 7 | }, 8 | }, 9 | } -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "stories": [ 3 | "../src/**/*.stories.mdx", 4 | "../src/**/*.stories.@(js|jsx|ts|tsx)" 5 | ], 6 | "addons": [ 7 | "@storybook/addon-links", 8 | "@storybook/addon-essentials", 9 | "@storybook/addon-actions" 10 | 11 | ], 12 | "framework": "@storybook/react" 13 | } -------------------------------------------------------------------------------- /semantic-release-templates/commit-template.hbs: -------------------------------------------------------------------------------- 1 | [`{{commit.short}}`](https://github.com/{{owner}}/{{repo}}/commit/{{commit.short}}) {{subject}} - {{message}} {{#if issues}}(Issues:{{#each issues}} [`{{text}}`]({{link}}){{/each}}){{/if}}{{#if wip}}{{#each wip}} 2 | - [`{{commit.short}}`](https://github.com/{{owner}}/{{repo}}/commit/{{commit.short}}) {{subject}}{{/each}} 3 | 4 | {{/if}} -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "root": true, 4 | "parser": "@typescript-eslint/parser", 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "rules": { 14 | "@typescript-eslint/no-explicit-any": "off", 15 | "@typescript-eslint/ban-ts-comment": "off" 16 | } 17 | } -------------------------------------------------------------------------------- /src/shapes/shape.factory.ts: -------------------------------------------------------------------------------- 1 | import {JoystickShape} from "../enums/shape.enum"; 2 | 3 | export const shapeFactory = (shape: JoystickShape, size: number) =>{ 4 | switch (shape){ 5 | 6 | case JoystickShape.Square: 7 | return { 8 | borderRadius: Math.sqrt(size) 9 | } 10 | case JoystickShape.Circle: 11 | default: 12 | return { 13 | borderRadius:size, 14 | }; 15 | } 16 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "rules": { 4 | "max-line-length": { 5 | "options": [120] 6 | }, 7 | "new-parens": true, 8 | "no-arg": true, 9 | "no-bitwise": true, 10 | "no-conditional-assignment": true, 11 | "no-consecutive-blank-lines": false, 12 | "no-console": { 13 | "severity": "warning", 14 | "options": ["debug", "info", "log", "time", "timeEnd", "trace"] 15 | } 16 | }, 17 | "jsRules": { 18 | "max-line-length": { 19 | "options": [120] 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /src/stories.css: -------------------------------------------------------------------------------- 1 | .tilt-manual-input { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: center; 5 | align-items: center; 6 | } 7 | .react-parallax-tilt { 8 | display: flex; 9 | flex-direction: column; 10 | justify-content: center; 11 | align-items: center; 12 | width: 300px; 13 | height: 300px; 14 | font-size: 35px; 15 | font-style: italic; 16 | background-color: darkgreen; 17 | color: white; 18 | border: 5px solid black; 19 | border-radius: 20px; 20 | } 21 | 22 | .manual-input { 23 | display: flex; 24 | flex-direction: column; 25 | align-items: flex-start; 26 | margin-top: 20px; 27 | } 28 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "build/lib", 4 | "module": "commonjs", 5 | "target": "es5", 6 | "lib": ["es5", "es6", "es7", "es2017", "dom"], 7 | "sourceMap": true, 8 | "allowJs": false, 9 | "jsx": "react", 10 | "moduleResolution": "node", 11 | "rootDir": "src", 12 | "baseUrl": "src", 13 | "forceConsistentCasingInFileNames": true, 14 | "noImplicitReturns": true, 15 | "noImplicitThis": true, 16 | "noImplicitAny": true, 17 | "strictNullChecks": true, 18 | "suppressImplicitAnyIndexErrors": true, 19 | "noUnusedLocals": true, 20 | "declaration": true, 21 | "allowSyntheticDefaultImports": true, 22 | "experimentalDecorators": true 23 | }, 24 | "include": ["src/**/*", "types/**/*"], 25 | "exclude": ["node_modules", "build", "scripts", "src/Joystick.stories.tsx", "src/Joystick.test.tsx"] 26 | } 27 | -------------------------------------------------------------------------------- /semantic-release-templates/default-template.hbs: -------------------------------------------------------------------------------- 1 | {{#if compareUrl}} 2 | # [v{{nextRelease.version}}]({{compareUrl}}) ({{datetime "UTC:yyyy-mm-dd"}}) 3 | {{else}} 4 | # v{{nextRelease.version}} ({{datetime "UTC:yyyy-mm-dd"}}) 5 | {{/if}} 6 | 7 | {{#with commits}} 8 | {{#if sparkles}} 9 | ## ✨ New Features 10 | {{#each sparkles}} 11 | - {{> commitTemplate}} 12 | {{/each}} 13 | {{/if}} 14 | 15 | {{#if bug}} 16 | ## 🐛 Bug Fixes 17 | {{#each bug}} 18 | - {{> commitTemplate}} 19 | {{/each}} 20 | {{/if}} 21 | 22 | {{#if ambulance}} 23 | ## 🚑 Critical Hotfixes 24 | {{#each ambulance}} 25 | - {{> commitTemplate}} 26 | {{/each}} 27 | {{/if}} 28 | 29 | {{#if lock}} 30 | ## 🔒 Security Issues 31 | {{#each lock}} 32 | - {{> commitTemplate}} 33 | {{/each}} 34 | {{/if}} 35 | 36 | {{#if boom}} 37 | ## 💥 Breaking Changes 38 | {{#each boom}} 39 | - {{> commitTemplate}} 40 | {{/each}} 41 | {{/if}} 42 | {{/with}} -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Jack 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 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | # dx-scanner: 8 | # runs-on: ubuntu-latest 9 | # container: dxheroes/dx-scanner:latest 10 | # steps: 11 | # - uses: actions/checkout@v1 12 | # - name: Runs DX Scanner on the code 13 | # env: 14 | # GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 15 | # DXSCANNER_API_TOKEN: ${{ secrets.DXSCANNER_API_TOKEN }} 16 | # run: dx-scanner run --ci 17 | release: 18 | name: Release 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Set vars 22 | id: vars 23 | run: echo ::set-output name=branch_name::${GITHUB_REF#refs/*/} 24 | - name: Checkout 25 | uses: actions/checkout@v2 26 | with: 27 | fetch-depth: 0 28 | - name: Setup Node.js 29 | uses: actions/setup-node@v2 30 | with: 31 | node-version: '16' 32 | - name: Install dependencies 33 | run: yarn install --frozen-lockfile 34 | - name: Run tests 35 | run: yarn test 36 | - name: Build project 37 | run: yarn build 38 | - name: Semantic Release 39 | id: semantic 40 | uses: cycjimmy/semantic-release-action@v2 41 | with: 42 | semantic_version: 19 43 | env: 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 46 | - name: Deploy storybook to Github Pages 47 | run: npm run deploy-storybook -- --ci 48 | # 🤷 49 | env: 50 | GH_TOKEN: ${{github.actor}}:${{ secrets.GITHUB_TOKEN }} 51 | 52 | -------------------------------------------------------------------------------- /src/shapes/shape.bounds.factory.ts: -------------------------------------------------------------------------------- 1 | import {JoystickShape} from "../enums/shape.enum"; 2 | 3 | export const shapeBoundsFactory = ( 4 | shape: JoystickShape, 5 | absoluteX:number, 6 | absoluteY: number, 7 | relativeX:number, 8 | relativeY:number, 9 | dist:number, 10 | radius:number, 11 | baseSize: number, 12 | parentRect: DOMRect) => { 13 | switch (shape){ 14 | case JoystickShape.Square: 15 | relativeX = getWithinBounds(absoluteX - parentRect.left - (baseSize / 2), baseSize); 16 | relativeY = getWithinBounds(absoluteY - parentRect.top - (baseSize / 2), baseSize); 17 | return {relativeX, relativeY}; 18 | case JoystickShape.AxisX: 19 | relativeX = getWithinBounds(absoluteX - parentRect.left - (baseSize / 2), baseSize); 20 | relativeY = 0; 21 | return {relativeX, relativeY}; 22 | 23 | case JoystickShape.AxisY: 24 | relativeX = 0 25 | relativeY = getWithinBounds(absoluteY - parentRect.top - (baseSize / 2), baseSize); 26 | return {relativeX, relativeY}; 27 | default: 28 | if (dist > radius) { 29 | relativeX *= radius / dist; 30 | relativeY *= radius / dist; 31 | } 32 | return {relativeX, relativeY}; 33 | 34 | } 35 | 36 | } 37 | 38 | const getWithinBounds = (value:number, baseSize:number): number => { 39 | const halfBaseSize = baseSize / 2; 40 | if(value > halfBaseSize){ 41 | return halfBaseSize; 42 | } 43 | if(value < -(halfBaseSize)){ 44 | return halfBaseSize * -1; 45 | } 46 | return value 47 | } -------------------------------------------------------------------------------- /.releaserc.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | // Given a `const` variable `TEMPLATE_DIR` which points to "/lib/assets/templates" 6 | 7 | // the *.hbs template and partials should be passed as strings of contents 8 | const template = fs.readFileSync(path.join('semantic-release-templates', 'default-template.hbs')) 9 | const commitTemplate = fs.readFileSync(path.join('semantic-release-templates', 'commit-template.hbs')) 10 | 11 | module.exports = { 12 | branches: ["master"], 13 | plugins: [ 14 | [ 15 | 'semantic-release-gitmoji', { 16 | releaseRules: { 17 | major: [ ':boom:' ], 18 | minor: [ ':sparkles:', ':sparkle' ], 19 | patch: [ 20 | ':bug:', 21 | ':ambulance:', 22 | ':lock:' 23 | ] 24 | }, 25 | releaseNotes: { 26 | template, 27 | partials: { commitTemplate }, 28 | helpers: { 29 | datetime: function () { 30 | const date = new Date(); 31 | return date.toLocaleString('en-US', { 32 | weekday: 'short', 33 | month: 'long', 34 | day: '2-digit', 35 | year: 'numeric' 36 | }); 37 | } 38 | }, 39 | issueResolution: { 40 | template: '{baseUrl}/{owner}/{repo}/issues/{ref}', 41 | baseUrl: 'https://github.com', 42 | source: 'github.com' 43 | } 44 | } 45 | } 46 | ], 47 | '@semantic-release/github', 48 | '@semantic-release/npm' 49 | ], 50 | tagFormat: '${version}', 51 | 52 | 53 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-joystick-component", 3 | "description": "A snazzy React joystick component", 4 | "version": "1.4.0", 5 | "keywords": [ 6 | "react", 7 | "joystick", 8 | "control", 9 | "component", 10 | "typescript" 11 | ], 12 | "main": "build/lib/index.js", 13 | "types": "build/lib/index.d.ts", 14 | "files": [ 15 | "build/lib", 16 | "src" 17 | ], 18 | "scripts": { 19 | "storybook": "start-storybook -p 6006 -c .storybook", 20 | "build": "tsc", 21 | "prepublish": "yarn build", 22 | "deploy-storybook": "storybook-to-ghpages", 23 | "test": "react-scripts test", 24 | "lint": "tslint -c tslint.json 'src/**/*.tsx' --fix && eslint src/**/*.tsx --fix --ext .ts" 25 | }, 26 | "repository": "https://github.com/elmarti/react-joystick-component", 27 | "author": "elmarti elmarti.tech@gmail.com", 28 | "license": "MIT", 29 | "devDependencies": { 30 | "@storybook/addon-actions": "^6.4.9", 31 | "@storybook/addon-essentials": "^6.4.9", 32 | "@storybook/addon-links": "^6.4.9", 33 | "@storybook/react": "^6.4.9", 34 | "@storybook/storybook-deployer": "^2.8.16", 35 | "@testing-library/jest-dom": "^5.16.5", 36 | "@types/jest": "^27.4.0", 37 | "@types/react": "17.0.38", 38 | "@types/react-dom": "^17.0.11", 39 | "@types/storybook__addon-actions": "^5.2.1", 40 | "@typescript-eslint/eslint-plugin": "^5.9.0", 41 | "@typescript-eslint/parser": "^5.9.0", 42 | "awesome-typescript-loader": "^5.2.1", 43 | "babel-core": "^6.26.0", 44 | "babel-loader": "8.2.3", 45 | "cpx": "^1.5.0", 46 | "eslint": "^8.6.0", 47 | "jest": "^27.4.7", 48 | "react": "17.0.2", 49 | "react-docgen-typescript-webpack-plugin": "^1.1.0", 50 | "react-dom": "17.0.2", 51 | "react-parallax-tilt": "^1.5.85", 52 | "semantic-release-gitmoji": "^1.4.2", 53 | "storybook-addon-jsx": "^7.3.14", 54 | "ts-jest": "^27.1.2", 55 | "tslint": "^6.1.3", 56 | "typescript": "4.5.4", 57 | "@testing-library/react": "12.1.2", 58 | "react-scripts": "^5.0.1" 59 | }, 60 | "peerDependencies": { 61 | "react": ">=17.0.2", 62 | "react-dom": ">=17.0.2" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## React Joystick Component 2 | 3 | [![Bundle Size](https://img.shields.io/bundlephobia/minzip/react-joystick-component?style=plastic)](https://img.shields.io/bundlephobia/minzip/react-joystick-component?style=plastic) 4 | 5 | [Click here to see examples](https://elmarti.github.io/react-joystick-component/) 6 | 7 | 8 | ``` 9 | npm i react-joystick-component --save 10 | yarn add react-joystick-component 11 | ``` 12 | 13 | ``` 14 | import { Joystick } from 'react-joystick-component'; 15 | ``` 16 | 17 | 18 | ```React 19 | 20 | ``` 21 | 22 | Component Props - as described by IJoystickProps - all are optional 23 | 24 | | Prop | Type | Description | 25 | |---|---|---| 26 | | size | number | The size in px of the Joystick base | 27 | | stickSize | number | The size in px of the Joystick stick (if unspecified, joystick size is relative to the `size` value | 28 | | baseColor | string | The color of the Joystick base | 29 | | stickColor | string | The color of the Stick | 30 | | throttle | number | The [throttling](https://codeburst.io/throttling-and-debouncing-in-javascript-b01cad5c8edf) rate of the move callback | 31 | | sticky | Boolean | Should the joystick stay where it is when the interaction ends | 32 | | stickImage | string | The image to be shown for the joystick | 33 | | baseImage | string | The image to be shown for the pad | 34 | | followCursor | Boolean | Make the stick follow the cursor position | 35 | | move | Function | Callback fired on every mouse move, not throttled unless a throttling rate is provided as above | 36 | | stop | Function | Callback fired when the user releases the joystick | 37 | | start | Function | Callback fired when the user starts moving the Joystick | 38 | | disabled | Boolean | When true, block any usage of the Joystick. This will also apply the `joystick-disabled` and `joystick-base-disabled` classNames | 39 | | stickShape | JoystickShape | The shape of the joystick default = circle| 40 | | baseShape | JoystickShape | The shape of the joystick default = circle| 41 | | controlPlaneShape | JoystickShape | Override the default shape behaviour of the control plane - circle, square, axisX, axisY| 42 | | minDistance | number | Percentage 0-100 - the minimum distance to start receive IJoystickMove events| 43 | | pos | {x: number, y: number}| Override the joystick position (doesn't work if the user is interacting. You can use `disabled` to force this)| 44 | ```TypeScript 45 | import {JoystickShape} from "./shape.enum"; 46 | interface IJoystickProps { 47 | size?: number; 48 | stickSize?: number; 49 | baseColor?: string; 50 | stickColor?: string; 51 | disabled?: boolean; 52 | throttle?: number; 53 | sticky?: boolean; 54 | stickImage?: string; 55 | baseImage?: string; 56 | followCursor?: boolean; 57 | move?: (event: IJoystickUpdateEvent) => void; 58 | stop?: (event: IJoystickUpdateEvent) => void; 59 | start?: (event: IJoystickUpdateEvent) => void; 60 | baseShape?: JoystickShape; 61 | stickShape?: JoystickShape; 62 | controlPlaneShape?: JoystickShape; 63 | minDistance?: number; 64 | pos: {x: number, y: number} 65 | } 66 | ``` 67 | 68 | ```TypeScript 69 | type JoystickDirection = "FORWARD" | "RIGHT" | "LEFT" | "BACKWARD"; 70 | 71 | export interface IJoystickUpdateEvent { 72 | type: "move" | "stop" | "start"; 73 | x: number | null; 74 | y: number | null; 75 | direction: JoystickDirection | null; 76 | distance: number; // Percentile 0-100% of joystick 77 | } 78 | ``` 79 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /src/Joystick.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render, fireEvent, screen } from '@testing-library/react'; 4 | import { Joystick, IJoystickProps } from './Joystick'; 5 | import { JoystickShape } from 'enums/shape.enum'; 6 | import '@testing-library/jest-dom'; 7 | 8 | const defaultProps: IJoystickProps = { 9 | size: 100, 10 | stickSize: 50, 11 | baseColor: '#000033', 12 | stickColor: '#3D59AB', 13 | throttle: 0, 14 | disabled: false, 15 | sticky: false, 16 | followCursor: false, 17 | baseShape: JoystickShape.Circle, 18 | stickShape: JoystickShape.Circle, 19 | controlPlaneShape: JoystickShape.Circle, 20 | minDistance: 0, 21 | }; 22 | // setPointerCapture is not available in JSDom 23 | Element.prototype.setPointerCapture = function () {}; 24 | 25 | 26 | describe('Joystick component', () => { 27 | test('renders without crashing', () => { 28 | render(); 29 | }); 30 | 31 | test('calls the start, move, and stop callbacks when appropriate', () => { 32 | const onStart = jest.fn(); 33 | const onMove = jest.fn(); 34 | const onStop = jest.fn(); 35 | 36 | render( 37 | 43 | ); 44 | 45 | const joystick = screen.getByRole('button'); 46 | 47 | fireEvent.pointerDown(joystick, { clientX: 50, clientY: 50 }); 48 | expect(onStart).toHaveBeenCalledTimes(1); 49 | 50 | fireEvent.pointerMove(window, { clientX: 60, clientY: 60 }); 51 | expect(onMove).toHaveBeenCalledTimes(1); 52 | 53 | fireEvent.pointerUp(window, { clientX: 60, clientY: 60 }); 54 | expect(onStop).toHaveBeenCalledTimes(1); 55 | }); 56 | 57 | test('respects the disabled prop', () => { 58 | const onStart = jest.fn(); 59 | const onMove = jest.fn(); 60 | const onStop = jest.fn(); 61 | 62 | render( 63 | 70 | ); 71 | 72 | const joystick = screen.getByRole('button'); 73 | 74 | fireEvent.pointerDown(joystick, { clientX: 50, clientY: 50 }); 75 | expect(onStart).not.toHaveBeenCalled(); 76 | 77 | fireEvent.pointerMove(window, { clientX: 60, clientY: 60 }); 78 | expect(onMove).not.toHaveBeenCalled(); 79 | 80 | fireEvent.pointerUp(window, { clientX: 60, clientY: 60 }); 81 | expect(onStop).not.toHaveBeenCalled(); 82 | }); 83 | 84 | test('follows the cursor when followCursor prop is set to true', () => { 85 | const onStart = jest.fn(); 86 | const onMove = jest.fn(); 87 | const onStop = jest.fn(); 88 | 89 | render( 90 | 97 | ); 98 | 99 | fireEvent.pointerMove(window, { clientX: 60, clientY: 60 }); 100 | expect(onMove).toHaveBeenCalledTimes(1); 101 | 102 | }); 103 | 104 | test('applies custom styles based on the baseColor, stickColor, size, and stickSize props', () => { 105 | render( 106 | 113 | ); 114 | const joystick = screen.getByRole('button'); 115 | 116 | expect(joystick).toHaveStyle({ backgroundColor: 'blue', width: '100px', height: '100px' }); 117 | const baseElement = screen.getByTestId('joystick-base'); 118 | expect(baseElement).toHaveStyle({ 119 | width: '200px', height: '200px' }); 120 | 121 | }); 122 | 123 | 124 | // test('applies sticky behavior when sticky prop is set to true', async () => { 125 | // let lastMovePosition = { x: 0, y: 0 }; 126 | // let lastStopPosition = { x: 0, y: 0 }; 127 | // const handleMove = (event) => { 128 | // lastMovePosition = { x: event.x, y: event.y }; 129 | // }; 130 | // const handleStop = (event) => { 131 | // lastStopPosition = { x: event.x, y: event.y}; 132 | // }; 133 | 134 | // const { getByRole } = render( 135 | // 136 | // ); 137 | 138 | // const stick = getByRole('button'); 139 | 140 | // fireEvent.pointerDown(stick, { clientX: 0, clientY: 0 }); 141 | // fireEvent.pointerMove(window, { clientX: 10, clientY: 10 }); 142 | // fireEvent.pointerUp(window, { clientX: 10, clientY: 10 }); 143 | 144 | // // Add a wait for the events to propagate 145 | // await waitFor(() => { 146 | // expect(lastStopPosition.x).toBe(lastMovePosition.x); 147 | // expect(lastStopPosition.y).toBe(lastMovePosition.y); 148 | // }); 149 | // }); 150 | 151 | 152 | 153 | // it('should not trigger the move callback if distance is less than minDistance', async () => { 154 | // const moveCallback = jest.fn(); 155 | // const { getByTestId } = render( 156 | // 160 | // ); 161 | 162 | // const joystickBase = getByTestId('joystick-base'); 163 | // const stick = joystickBase.querySelector('button'); 164 | // const baseRect = joystickBase.getBoundingClientRect(); 165 | // const centerX = baseRect.left + baseRect.width / 2; 166 | // const centerY = baseRect.top + baseRect.height / 2; 167 | 168 | // console.log(stick) 169 | // fireEvent.pointerDown(stick, { clientX: centerX, clientY: centerY }); 170 | 171 | // await act(async () => { 172 | // // Add a small delay 173 | // await new Promise((resolve) => setTimeout(resolve, 50)); 174 | // }); 175 | 176 | // fireEvent.pointerMove(window, { clientX: centerX + 1, clientY: centerY + 1 }); // Move a larger distance 177 | // fireEvent.pointerUp(window, { clientX: centerX + 1, clientY: centerY + 1 }); 178 | 179 | // expect(moveCallback).not.toHaveBeenCalled(); 180 | // }); 181 | 182 | 183 | test('renders the joystick stick at the correct position when the pos prop is provided', () => { 184 | const pos = { x: 0.4, y: -0.6 }; 185 | const size = 100; 186 | const expectedStickPosition = { 187 | x: (pos.x * size) / 2, 188 | y: -(pos.y * size) / 2, 189 | }; 190 | 191 | render(); 192 | const stick = screen.getByRole('button'); 193 | 194 | expect(stick).toHaveStyle({ 195 | transform: `translate3d(${expectedStickPosition.x}px, ${expectedStickPosition.y}px, 0)`, 196 | }); 197 | }); 198 | 199 | }); 200 | -------------------------------------------------------------------------------- /src/Joystick.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactDOM from "react-dom"; 3 | import {storiesOf} from "@storybook/react"; 4 | import {action} from '@storybook/addon-actions'; 5 | import Tilt from 'react-parallax-tilt'; 6 | 7 | import {IJoystickUpdateEvent, Joystick} from "./Joystick"; 8 | import {JoystickShape} from "./enums/shape.enum"; 9 | import {useState} from "react"; 10 | import './stories.css'; 11 | 12 | const joystickStories = storiesOf('Joystick Examples', module); 13 | 14 | 15 | joystickStories.add("Default joystick", () => ); 17 | joystickStories.add("Default joystick with small stick", () => ); 19 | joystickStories.add("Default joystick with 50% minDistance", () => ); 21 | joystickStories.add("Control plane override", () => ); 23 | joystickStories.add("Square joystick", () =>
); 26 | 27 | joystickStories.add("Yellow (custom colors) joystick", 28 | () => ); 33 | 34 | joystickStories.add("Position override", 35 | () => ); 41 | 42 | joystickStories.add("Position override with second joystick", 43 | () => { 44 | const [joystickPos, setJoystickPos] = useState({x:0, y:0}); 45 | const handleMove = (event) => { 46 | setJoystickPos({x: event.x, y: event.y}) 47 | }; 48 | return <> 49 | 50 | 51 | 52 | 53 | ;}); 54 | 55 | joystickStories.add("Y Axis only", 56 | () => ); 59 | joystickStories.add("X Axis only", 60 | () => ); 63 | joystickStories.add("50ms throttled joystick", () => ); 65 | 66 | joystickStories.add("100ms throttled joystick", 67 | () => ); 72 | 73 | joystickStories.add("200ms throttled joystick", 74 | () => ); 79 | 80 | 81 | joystickStories.add("500ms throttled joystick", 82 | () => ); 87 | 88 | joystickStories.add("Sticky joystick", 89 | () => ); 95 | 96 | joystickStories.add("Images", () => ); 100 | 101 | joystickStories.add("Follow Cursor", () => (
102 | 104 | 106 |
)); 107 | joystickStories.add("Many follow Cursor", () => (
108 | 110 | 112 | 122 | 124 |
)); 125 | 126 | joystickStories.add("HUGE joystick", () => ); 128 | joystickStories.add("Tiny joystick", () => ); 130 | joystickStories.add("Disabled joystick", () => ); 132 | 133 | 134 | joystickStories.add("Controlling a react-parallax-tilt ", () => { 135 | const [[manualTiltAngleX, manualTiltAngleY], setManualTiltAngle] = useState([0, 0] as Array); 136 | 137 | const onMove = (stick:IJoystickUpdateEvent) => { 138 | //@ts-ignore 139 | setManualTiltAngle([stick.y * 100, stick.x * 100]); 140 | }; 141 | 142 | const onStop = () => { 143 | setManualTiltAngle([0, 0]); 144 | }; 145 | return <> 146 |
147 | 148 |
149 |
Axis x: {manualTiltAngleX?.toFixed(0)}°
150 |
Axis y: {manualTiltAngleY?.toFixed(0)}°
151 |
152 |
153 |
154 | 155 |
156 |

Thanks to react-parallax-tilt

157 |
158 | } 159 | ) 160 | 161 | interface IDirectionComponentState { 162 | direction: string; 163 | } 164 | 165 | 166 | class DirectionComponent extends React.Component { 167 | constructor(props: any) { 168 | super(props); 169 | this.state = { 170 | direction: "Stopped" 171 | }; 172 | } 173 | 174 | private _handleMove(data: any) { 175 | this.setState({ 176 | direction: data.direction 177 | }); 178 | } 179 | 180 | private _handleStop() { 181 | this.setState({ 182 | direction: "Stopped" 183 | }); 184 | } 185 | 186 | render() { 187 | return (
188 | 189 |

{this.state.direction}

190 |
); 191 | } 192 | } 193 | 194 | joystickStories.add("Default with direction text", () => ) 195 | 196 | const Modal =({ isOpen}: {isOpen:boolean})=> { 197 | if (!isOpen) return null 198 | return ReactDOM.createPortal( 199 | 200 | , 201 | document.body) 202 | } 203 | 204 | 205 | joystickStories.add("Default with portal", () => { 206 | const [isOpen, setIsOpen] = useState(false) as any; 207 | return <> 208 | 209 | 210 | }) -------------------------------------------------------------------------------- /src/Joystick.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {JoystickShape} from "./enums/shape.enum"; 3 | import {shapeFactory} from "./shapes/shape.factory"; 4 | import {shapeBoundsFactory} from "./shapes/shape.bounds.factory"; 5 | 6 | export interface IJoystickProps { 7 | size?: number; 8 | stickSize?: number; 9 | baseColor?: string; 10 | stickColor?: string; 11 | throttle?: number; 12 | disabled?: boolean; 13 | sticky?: boolean; 14 | move?: (event: IJoystickUpdateEvent) => void; 15 | stop?: (event: IJoystickUpdateEvent) => void; 16 | start?: (event: IJoystickUpdateEvent) => void; 17 | stickImage?: string; 18 | baseImage?: string; 19 | followCursor?: boolean; 20 | baseShape?: JoystickShape; 21 | stickShape?: JoystickShape; 22 | controlPlaneShape?: JoystickShape; 23 | minDistance?: number; 24 | pos?: {x: number, y: number}; 25 | } 26 | 27 | 28 | enum InteractionEvents { 29 | PointerDown = "pointerdown", 30 | PointerMove = "pointermove", 31 | PointerUp = "pointerup" 32 | } 33 | 34 | export interface IJoystickUpdateEvent { 35 | type: "move" | "stop" | "start"; 36 | // TODO: these could just be optional, but this may be a breaking change 37 | x: number | null; 38 | y: number | null; 39 | direction: JoystickDirection | null; 40 | distance: number | null; 41 | } 42 | 43 | export interface IJoystickState { 44 | dragging: boolean; 45 | coordinates?: IJoystickCoordinates; 46 | } 47 | 48 | type JoystickDirection = "FORWARD" | "RIGHT" | "LEFT" | "BACKWARD"; 49 | 50 | export interface IJoystickCoordinates { 51 | relativeX: number; 52 | relativeY: number; 53 | axisX: number; 54 | axisY: number; 55 | direction: JoystickDirection; 56 | distance: number; 57 | } 58 | 59 | 60 | /** 61 | * Radians identifying the direction of the joystick 62 | */ 63 | enum RadianQuadrantBinding { 64 | TopRight = 2.35619449, 65 | TopLeft = -2.35619449, 66 | BottomRight = 0.785398163, 67 | BottomLeft = -0.785398163 68 | } 69 | 70 | class Joystick extends React.Component { 71 | private readonly _stickRef: React.RefObject = React.createRef(); 72 | private readonly _baseRef: React.RefObject = React.createRef(); 73 | private readonly _throttleMoveCallback: (data: IJoystickUpdateEvent) => void; 74 | private _baseSize: number; 75 | private _stickSize?: number; 76 | private frameId: number | null = null; 77 | 78 | private _radius: number; 79 | private _parentRect: DOMRect; 80 | private _pointerId: number|null = null 81 | private _mounted = false; 82 | 83 | constructor(props: IJoystickProps) { 84 | super(props); 85 | this.state = { 86 | dragging: false 87 | }; 88 | this._throttleMoveCallback = (() => { 89 | let lastCall = 0; 90 | return (event: IJoystickUpdateEvent) => { 91 | 92 | const now = new Date().getTime(); 93 | const throttleAmount = this.props.throttle || 0; 94 | if (now - lastCall < throttleAmount) { 95 | return; 96 | } 97 | lastCall = now; 98 | if (this.props.move) { 99 | return this.props.move(event); 100 | } 101 | }; 102 | })(); 103 | 104 | 105 | 106 | } 107 | 108 | componentWillUnmount() { 109 | this._mounted = false; 110 | if (this.props.followCursor) { 111 | window.removeEventListener(InteractionEvents.PointerMove, event => this._pointerMove(event)); 112 | } 113 | if (this.frameId !== null) { 114 | window.cancelAnimationFrame(this.frameId); 115 | } 116 | } 117 | 118 | componentDidMount() { 119 | this._mounted = true; 120 | if (this.props.followCursor) { 121 | //@ts-ignore 122 | this._parentRect = this._baseRef.current.getBoundingClientRect(); 123 | 124 | this.setState({ 125 | dragging: true 126 | }); 127 | 128 | window.addEventListener(InteractionEvents.PointerMove, event => this._pointerMove(event)); 129 | 130 | if (this.props.start) { 131 | this.props.start({ 132 | type: "start", 133 | x: null, 134 | y: null, 135 | distance: null, 136 | direction: null 137 | }); 138 | } 139 | 140 | } 141 | } 142 | 143 | /** 144 | * Update position of joystick - set state and trigger DOM manipulation 145 | * @param coordinates 146 | * @private 147 | */ 148 | private _updatePos(coordinates: IJoystickCoordinates) { 149 | 150 | this.frameId = window.requestAnimationFrame(() => { 151 | if(this._mounted){ 152 | this.setState({ 153 | coordinates, 154 | }); 155 | } 156 | }); 157 | 158 | if(typeof this.props.minDistance === 'number'){ 159 | if(coordinates.distance < this.props.minDistance){ 160 | return; 161 | } 162 | } 163 | this._throttleMoveCallback({ 164 | type: "move", 165 | x: ((coordinates.relativeX * 2) / this._baseSize), 166 | y: -((coordinates.relativeY * 2) / this._baseSize), 167 | direction: coordinates.direction, 168 | distance: coordinates.distance 169 | }); 170 | 171 | } 172 | 173 | /** 174 | * Handle pointerdown event 175 | * @param e PointerEvent 176 | * @private 177 | */ 178 | private _pointerDown(e: PointerEvent) { 179 | if (this.props.disabled || this.props.followCursor) { 180 | return; 181 | } 182 | //@ts-ignore 183 | this._parentRect = this._baseRef.current.getBoundingClientRect(); 184 | 185 | this.setState({ 186 | dragging: true 187 | }); 188 | 189 | window.addEventListener(InteractionEvents.PointerUp, this._pointerUp); 190 | window.addEventListener(InteractionEvents.PointerMove, this._pointerMove); 191 | this._pointerId = e.pointerId 192 | //@ts-ignore 193 | this._stickRef.current.setPointerCapture(e.pointerId); 194 | 195 | if (this.props.start) { 196 | this.props.start({ 197 | type: "start", 198 | x: null, 199 | y: null, 200 | distance: null, 201 | direction: null 202 | }); 203 | } 204 | 205 | } 206 | 207 | /** 208 | * Use ArcTan2 (4 Quadrant inverse tangent) to identify the direction the joystick is pointing 209 | * https://docs.oracle.com/cd/B12037_01/olap.101/b10339/x_arcsin003.htm 210 | * @param atan2: number 211 | * @private 212 | */ 213 | private _getDirection(atan2: number): JoystickDirection { 214 | if (atan2 > RadianQuadrantBinding.TopRight || atan2 < RadianQuadrantBinding.TopLeft) { 215 | return "FORWARD"; 216 | } else if (atan2 < RadianQuadrantBinding.TopRight && atan2 > RadianQuadrantBinding.BottomRight) { 217 | return "RIGHT" 218 | } else if (atan2 < RadianQuadrantBinding.BottomLeft) { 219 | return "LEFT"; 220 | } 221 | return "BACKWARD"; 222 | 223 | 224 | } 225 | 226 | /** 227 | * Hypotenuse distance calculation 228 | * @param x: number 229 | * @param y: number 230 | * @private 231 | */ 232 | private _distance(x: number, y: number): number { 233 | return Math.hypot(x, y); 234 | } 235 | private _distanceToPercentile(distance:number): number { 236 | const percentageBaseSize = distance / (this._baseSize/2) * 100; 237 | if(percentageBaseSize > 100){ 238 | return 100; 239 | } 240 | return percentageBaseSize; 241 | } 242 | 243 | /** 244 | * Calculate X/Y and ArcTan within the bounds of the joystick 245 | * @param event 246 | * @private 247 | */ 248 | private _pointerMove = (event: PointerEvent) => { 249 | event.preventDefault() 250 | if (this.state.dragging) { 251 | if(!this.props.followCursor && event.pointerId !== this._pointerId) return; 252 | const absoluteX = event.clientX; 253 | const absoluteY = event.clientY; 254 | let relativeX = absoluteX - this._parentRect.left - this._radius; 255 | let relativeY = absoluteY - this._parentRect.top - this._radius; 256 | const dist = this._distance(relativeX, relativeY); 257 | // @ts-ignore 258 | const bounded = shapeBoundsFactory( 259 | //@ts-ignore 260 | this.props.controlPlaneShape || this.props.baseShape, 261 | absoluteX, 262 | absoluteY, 263 | relativeX, 264 | relativeY, 265 | dist, 266 | this._radius, 267 | this._baseSize, 268 | this._parentRect); 269 | relativeX = bounded.relativeX 270 | relativeY = bounded.relativeY 271 | const atan2 = Math.atan2(relativeX, relativeY); 272 | 273 | this._updatePos({ 274 | relativeX, 275 | relativeY, 276 | distance: this._distanceToPercentile(dist), 277 | direction: this._getDirection(atan2), 278 | axisX: absoluteX - this._parentRect.left, 279 | axisY: absoluteY - this._parentRect.top 280 | }); 281 | } 282 | } 283 | 284 | 285 | 286 | /** 287 | * Handle pointer up and de-register listen events 288 | * @private 289 | */ 290 | private _pointerUp = (event: PointerEvent) => { 291 | if(event.pointerId !== this._pointerId) return; 292 | const stateUpdate = { 293 | dragging: false, 294 | } as any; 295 | if (!this.props.sticky) { 296 | stateUpdate.coordinates = undefined; 297 | } 298 | this.frameId = window.requestAnimationFrame(() => { 299 | if(this._mounted){ 300 | this.setState(stateUpdate); 301 | } 302 | }); 303 | 304 | window.removeEventListener(InteractionEvents.PointerUp, this._pointerUp); 305 | window.removeEventListener(InteractionEvents.PointerMove, this._pointerMove); 306 | this._pointerId = null; 307 | if (this.props.stop) { 308 | this.props.stop({ 309 | type: "stop", 310 | // @ts-ignore 311 | x: this.props.sticky ? ((this.state.coordinates.relativeX * 2) / this._baseSize) : null, 312 | // @ts-ignore 313 | y: this.props.sticky ? ((this.state.coordinates.relativeY * 2) / this._baseSize): null, 314 | // @ts-ignore 315 | direction: this.props.sticky ? this.state.coordinates.direction : null, 316 | // @ts-ignore 317 | distance: this.props.sticky ? this.state.coordinates.distance : null 318 | 319 | }); 320 | } 321 | 322 | } 323 | 324 | /** 325 | * Get the shape stylings for the base 326 | * @private 327 | */ 328 | private getBaseShapeStyle() { 329 | const shape = this.props.baseShape || JoystickShape.Circle; 330 | return shapeFactory(shape, this._baseSize); 331 | } 332 | /** 333 | * Get the shape stylings for the stick 334 | * @private 335 | */ 336 | private getStickShapeStyle() { 337 | const shape = this.props.stickShape || JoystickShape.Circle; 338 | return shapeFactory(shape, this._baseSize); 339 | } 340 | /** 341 | * Calculate base styles for pad 342 | * @private 343 | */ 344 | private _getBaseStyle(): any { 345 | const baseColor: string = this.props.baseColor !== undefined ? this.props.baseColor : "#000033"; 346 | 347 | const baseSizeString = `${this._baseSize}px`; 348 | const padStyle = { 349 | ...this.getBaseShapeStyle(), 350 | height: baseSizeString, 351 | width: baseSizeString, 352 | background: baseColor, 353 | display: 'flex', 354 | justifyContent: 'center', 355 | alignItems: 'center', 356 | } as any; 357 | if (this.props.baseImage) { 358 | padStyle.background = `url(${this.props.baseImage})`; 359 | padStyle.backgroundSize = '100%' 360 | } 361 | return padStyle; 362 | 363 | } 364 | 365 | /** 366 | * Calculate base styles for joystick and translate 367 | * @private 368 | */ 369 | private _getStickStyle(): any { 370 | const stickColor: string = this.props.stickColor !== undefined ? this.props.stickColor : "#3D59AB"; 371 | const stickSize = this._stickSize ? `${this._stickSize}px` :`${this._baseSize / 1.5}px`; 372 | 373 | let stickStyle = { 374 | ...this.getStickShapeStyle(), 375 | background: stickColor, 376 | cursor: "move", 377 | height: stickSize, 378 | width: stickSize, 379 | border: 'none', 380 | flexShrink: 0, 381 | touchAction: 'none' 382 | } as any; 383 | if (this.props.stickImage) { 384 | stickStyle.background = `url(${this.props.stickImage})`; 385 | stickStyle.backgroundSize = '100%' 386 | } 387 | if(this.props.pos){ 388 | stickStyle = Object.assign({}, stickStyle, { 389 | position: 'absolute', 390 | transform: `translate3d(${(this.props.pos.x * this._baseSize)/2 }px, ${-(this.props.pos.y * this._baseSize)/2}px, 0)` 391 | }); 392 | } 393 | 394 | if (this.state.coordinates !== undefined) { 395 | stickStyle = Object.assign({}, stickStyle, { 396 | position: 'absolute', 397 | transform: `translate3d(${this.state.coordinates.relativeX}px, ${this.state.coordinates.relativeY}px, 0)` 398 | }); 399 | } 400 | return stickStyle; 401 | 402 | } 403 | 404 | render() { 405 | this._baseSize = this.props.size || 100; 406 | this._stickSize = this.props.stickSize; 407 | this._radius = this._baseSize / 2; 408 | const baseStyle = this._getBaseStyle(); 409 | const stickStyle = this._getStickStyle(); 410 | //@ts-ignore 411 | return ( 412 |
416 |
422 | ) 423 | } 424 | } 425 | 426 | export { 427 | Joystick 428 | }; 429 | --------------------------------------------------------------------------------