├── .gitignore ├── .prettierrc ├── .github └── workflows │ ├── ci.yml │ └── publish.yml ├── .eslintrc.json ├── src ├── playerUtils.js ├── inputUtils.js └── index.js ├── package.json ├── LICENCE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .npm 3 | .eslintcache 4 | dist 5 | .temp 6 | .cache 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "useTabs": true, 4 | "semi": true, 5 | "singleQuote": false, 6 | "printWidth": 100 7 | } -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | workflow_dispatch: 10 | 11 | jobs: 12 | Lint: 13 | runs-on: macos-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - name: Install npm packages 19 | run: npm ci eslint 20 | 21 | - name: Lint code 22 | run: npm run lint 23 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "prettier" 9 | ], 10 | "parserOptions": { 11 | "ecmaVersion": "latest", 12 | "sourceType": "module" 13 | }, 14 | "rules": { 15 | "indent": [ 16 | "error", 17 | "tab" 18 | ], 19 | "linebreak-style": [ 20 | "error", 21 | "unix" 22 | ], 23 | "quotes": [ 24 | "error", 25 | "double" 26 | ], 27 | "semi": [ 28 | "error", 29 | "always" 30 | ] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/playerUtils.js: -------------------------------------------------------------------------------- 1 | import { MathUtils } from "three"; 2 | 3 | function playerMove(delta) { 4 | // Move the player 5 | const speed = this.inputs.sprint ? this.sprintSpeed : this.walkSpeed; 6 | this.player.translateX(this.inputs.horizontalAxis * speed * delta); 7 | this.player.translateY(this.inputs.verticalAxis * speed * delta); 8 | } 9 | 10 | function playerLook(delta) { 11 | // Rotate the player left and right 12 | this.player.rotation.z += -this.mouse.x * delta * this.sensitivity.x; 13 | 14 | // Rotate the camera up and down 15 | this.player.children[0].rotation.x -= this.mouse.y * delta * this.sensitivity.y; 16 | } 17 | 18 | function playerClamp() { 19 | // Clamp the up and down camera movement 20 | this.camera.rotation.x = MathUtils.clamp( 21 | this.camera.rotation.x, 22 | MathUtils.degToRad(this.lookLimit.down), 23 | MathUtils.degToRad(this.lookLimit.up) 24 | ); 25 | } 26 | 27 | export { playerClamp, playerLook, playerMove }; 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "charactercontroller", 3 | "version": "3.0.2", 4 | "description": "A first person character controller for the Three.js graphics library", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "lint": "eslint src", 8 | "format": "prettier --write src" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/ma1ted/charactercontroller.git" 13 | }, 14 | "keywords": [ 15 | "threejs", 16 | "fps", 17 | "controller" 18 | ], 19 | "author": "malted", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/ma1ted/charactercontroller/issues" 23 | }, 24 | "homepage": "https://github.com/ma1ted/charactercontroller#readme", 25 | "devDependencies": { 26 | "eslint": "^8.16.0", 27 | "eslint-config-prettier": "^8.5.0", 28 | "prettier": "^2.6.2" 29 | }, 30 | "dependencies": { 31 | "three": "^0.141.0" 32 | }, 33 | "publishConfig": { 34 | "registry": "https://registry-url" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Malted 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 | -------------------------------------------------------------------------------- /src/inputUtils.js: -------------------------------------------------------------------------------- 1 | export function registerKeyEvents() { 2 | document.addEventListener("keydown", (e) => { 3 | this.keysDown[e.code] = true; 4 | }); 5 | document.addEventListener("keyup", (e) => { 6 | this.keysDown[e.code] = false; 7 | }); 8 | } 9 | 10 | export function registerMouseMoveEvent() { 11 | window.addEventListener("mousemove", (e) => { 12 | clearTimeout(this.cancelDriftTimeout); 13 | this.mouse.x = e.movementX; 14 | this.mouse.y = e.movementY; 15 | this.cancelDriftTimeout = setTimeout(() => { 16 | this.mouse.x = this.mouse.y = 0; 17 | }, 10); 18 | }); 19 | } 20 | 21 | export function checkInputs() { 22 | // Check scalar values 23 | Object.entries(this.inputMappings.scalar).forEach(([axisName, axisInfo]) => { 24 | this.inputs[axisName] = 0; 25 | axisInfo.forEach((axisInput) => { 26 | if (axisInput.inputs.some((code) => this.keysDown[code])) { 27 | this.inputs[axisName] += axisInput.value; 28 | } 29 | }); 30 | }); 31 | 32 | // Check discrete values 33 | Object.entries(this.inputMappings.discrete).forEach(([eventName, eventCodes]) => { 34 | this.inputs[eventName] = eventCodes.some((code) => this.keysDown[code]); 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | npm-publish: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout Repo 14 | uses: actions/checkout@main 15 | 16 | - name: Setup Node.js (NPM) 17 | uses: actions/setup-node@master 18 | with: 19 | node-version: '16.x' 20 | registry-url: 'https://registry.npmjs.org' 21 | 22 | - name: Install dependencies 23 | run: npm ci 24 | 25 | - name: Update Publish Config 26 | run: sed -i 's^registry-url^registry.npmjs.org^' package.json 27 | 28 | - name: Publish to NPM 29 | run: npm publish 30 | env: 31 | NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 32 | 33 | gpr-publish: 34 | runs-on: ubuntu-latest 35 | 36 | steps: 37 | - name: Checkout Repo 38 | uses: actions/checkout@main 39 | 40 | - name: Store lowercase actor name 41 | run: | 42 | echo 'actor_name<> $GITHUB_ENV 43 | echo ${{ github.actor }} | tr "A-Z" "a-z" >> $GITHUB_ENV 44 | echo 'EOF' >> $GITHUB_ENV 45 | - name: Store package name 46 | run: | 47 | echo 'package_name<> $GITHUB_ENV 48 | grep -Po '"name": *\K"[^"]*"' package.json | grep -oP '"\K[^"\047]+(?=["\047])' >> $GITHUB_ENV 49 | echo 'EOF' >> $GITHUB_ENV 50 | - name: Setup Node.js (GPR) 51 | uses: actions/setup-node@master 52 | with: 53 | node-version: '16.x' 54 | registry-url: https://npm.pkg.github.com/ 55 | scope: '${{ env.actor_name }}' 56 | 57 | - name: Install dependencies 58 | run: npm ci 59 | 60 | - name: Update Package Name 61 | run: | 62 | sed -i 's,"name": "${{ env.package_name }}","name": "@${{ env.actor_name }}/${{ env.package_name }}",' package.json 63 | cat package.json 64 | - name: Update Publish Config 65 | run: | 66 | sed -i 's^registry-url^npm.pkg.github.com/@${{ env.actor_name }}^' package.json 67 | cat package.json 68 | - name: Publish to GitHub Package Registry 69 | run: npm publish 70 | env: 71 | NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 72 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { Group, Clock, PerspectiveCamera, Raycaster, Vector3, MathUtils } from "three"; 2 | import * as PlayerUtils from "./playerUtils.js"; 3 | import * as InputUtils from "./inputUtils.js"; 4 | 5 | export default class CharacterController { 6 | constructor( 7 | scene, 8 | { 9 | walkSpeed = 5, 10 | sprintSpeed = 10, 11 | floorDistance = 1, 12 | gravity = -9.81, 13 | jumpPower = 5, 14 | sensitivity = { 15 | x: 0.1, 16 | y: 0.1, 17 | }, 18 | lookLimit = { 19 | down: 0, 20 | up: 180, 21 | }, 22 | cameraFov = 75, 23 | inputMappings = { 24 | scalar: { 25 | horizontalAxis: [ 26 | { inputs: ["KeyA", "ArrowLeft"], value: -1 }, 27 | { inputs: ["KeyD", "ArrowRight"], value: 1 }, 28 | ], 29 | verticalAxis: [ 30 | { inputs: ["KeyS", "ArrowDown"], value: -1 }, 31 | { inputs: ["KeyW", "ArrowUp"], value: 1 }, 32 | ], 33 | }, 34 | discrete: { 35 | jump: ["Space"], 36 | sprint: ["ShiftLeft", "ShiftRight"], 37 | }, 38 | }, 39 | } 40 | ) { 41 | this.scene = scene; 42 | 43 | this.walkSpeed = walkSpeed; 44 | this.sprintSpeed = sprintSpeed; 45 | this.floorDistance = floorDistance; 46 | this.gravity = gravity; 47 | this.jumpPower = jumpPower; 48 | this.sensitivity = sensitivity; 49 | this.lookLimit = lookLimit; 50 | this.cameraFov = cameraFov; 51 | this.inputMappings = inputMappings; 52 | 53 | this.player = new Group(); 54 | this.clock = new Clock(); 55 | this.camera = new PerspectiveCamera( 56 | this.cameraFov, 57 | window.innerWidth / window.innerHeight, 58 | 0.1, 59 | 1000 60 | ); 61 | this.camera.rotation.x = MathUtils.degToRad(90); 62 | this.player.add(this.camera); 63 | 64 | // A raw list of all the keys currently pressed. Internal use only. 65 | this.keysDown = {}; 66 | 67 | // A processed list of inputs corresponding to the input mappings. 68 | this.inputs = {}; 69 | this.mouse = { x: 0, y: 0 }; 70 | 71 | this.isGrounded; 72 | this.velocity = 0; 73 | 74 | this.raycaster = new Raycaster( 75 | this.player.position, 76 | new Vector3(0, 0, -1), 77 | 0, 78 | this.floorDistance 79 | ); 80 | 81 | this.cancelDriftTimeout; 82 | InputUtils.registerMouseMoveEvent.call(this); 83 | 84 | InputUtils.registerKeyEvents.call(this); 85 | 86 | this.clock.start(); 87 | } 88 | 89 | update() { 90 | const clock = this.clock; 91 | const elapsed = this.clock.elapsedTime; 92 | const delta = clock.getDelta(); 93 | 94 | // Update the player's currently activated inputs for this frame. 95 | InputUtils.checkInputs.call(this); 96 | 97 | // Cast a ray straight down from the player's position. 98 | this.raycaster.set(this.player.position, new Vector3(0, 0, -1)); 99 | 100 | // An array of the objects the ray intersects with. 101 | const hits = this.raycaster.intersectObjects(this.scene.children, true); 102 | // If the list of objects the ray intersects with is not empty, the player is touching the ground. 103 | if (hits.length < 1) { 104 | this.isGrounded = false; 105 | } else { 106 | this.isGrounded = true; 107 | 108 | /* Snap the player's z position to the position of the 109 | first object the ray intersects with. This makes sure the 110 | player's position is always floorDistance away from the surface, 111 | as factors such as frame rate can affect where the 112 | player ends up when the ground check is carried out 113 | and the player's velocity is set to zero. */ 114 | this.player.position.z = hits[0].point.z + this.floorDistance; 115 | } 116 | 117 | /* The player's velocity is multiplied by delta time twice so it 118 | produces an accelerating gravitational force. 119 | Gravity is not a constant speed; it's an acceleration! 120 | Thanks to John French for help with this. */ 121 | this.velocity += this.gravity * delta; 122 | 123 | /* If the player is touching the ground, cancel their velocity. 124 | Their velocity is checked to be below 0 to make sure the velocity is 125 | not set to zero when they are going up, i.e. right after jumping, 126 | when their ground check ray is still intersecting with the floor. */ 127 | if (this.isGrounded && this.velocity < 0) { 128 | this.velocity = 0; 129 | } 130 | 131 | /* Make sure the player is on the floor before letting them jump again; 132 | otherwise the would be able to fly. Also, debouncing is not required 133 | for this as it would not matter if the velocity keeps being set to 134 | jumpPower for a short while after the jump has started. */ 135 | if (this.keysDown.Space && this.isGrounded) { 136 | this.velocity = this.jumpPower; 137 | } 138 | this.player.position.z += this.velocity * delta; 139 | 140 | PlayerUtils.playerMove.call(this, delta); 141 | PlayerUtils.playerLook.call(this, delta); 142 | PlayerUtils.playerClamp.call(this); 143 | 144 | return { elapsed, delta }; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # charactercontroller 2 | 3 | A first person character controller for the Three.js graphics library 4 | 5 | ![npm bundle size](https://img.shields.io/bundlephobia/minzip/charactercontroller) 6 | ![npm](https://img.shields.io/npm/v/charactercontroller) 7 | ![NPM](https://img.shields.io/npm/l/charactercontroller) 8 | 9 | ## [Demo](https://controller.malted.dev) 10 | 11 | ## Installation 12 | 13 | `npm install charactercontroller` 14 | 15 | ## Usage 16 | 17 | ```javascript 18 | import CharacterController from "charactercontroller"; 19 | 20 | // Scene & renderer initialisation... 21 | 22 | const controller = new CharacterController(scene, options); 23 | 24 | function animate() { 25 | requestAnimationFrame(animate); 26 | // ... 27 | controller.update(); 28 | renderer.render(scene, controller.camera); 29 | } 30 | ``` 31 | 32 | - `scene` 33 | > An instance of `THREE.Scene` that the Character Controller is to become a child of. 34 | 35 | - `options` 36 | > An object defining options for the Character Controller. The valid fields are described below 37 | 38 | ## Constructor Options 39 | 40 | - `walkSpeed` 41 | > The rate at which the controller is translated in the scene in response to player inputs, when the sprint key (left shift) **is not** being pressed. 42 | - Default: `5` 43 | 44 | - `sprintSpeed` 45 | > The rate at which the controller is translated in the scene in response to player inputs, when the sprint key (left shift) **is** being pressed. 46 | - Default: `10` 47 | 48 | - `floorDistance` 49 | > How far above a surface the controller can get before stopping falling. 50 | > 51 | > Could be interpreted as the height of the player. 52 | - Default: `1` 53 | 54 | - `gravity` 55 | > How quickly the controller is pulled down when there is no surface beneath it. 56 | - Default: `-9.81` 57 | 58 | - `jumpPower` 59 | > With how much force the controller is projected upwards when a jump is initiated. 60 | - Default: `5` 61 | 62 | - `sensitivity` 63 | - `x` 64 | 65 | > How much the camera should move in response to the player moving the mouse left and right. 66 | - Default: `0.1` 67 | - `y` 68 | > How much the camera should move in response to the player moving the mouse up and down. 69 | - Default: `0.1` 70 | - `lookLimit` 71 | - `down` 72 | 73 | > The angle in degrees that the camera's `x` rotation should be clamped to when looking down 74 | - Default: `0` 75 | - `up` 76 | 77 | > The angle in degrees that the camera's `x` rotation should be clamped to when looking up 78 | - Default: `180` 79 | 80 | - `cameraFov` 81 | > The field of view of the camera attatched to the character controller. 82 | - Default: `75` 83 | 84 | - `inputMappings` 85 | > The `KeyboardEvent.code`s that control the character controller. An array of `code`s are used to support multiple keys controlling the same actions; primarily for accessability reasons. 86 | 87 | - `scalar` 88 | 89 | > **Note** 90 | > 91 | > The scalar property defines axes that can take on any value within a range. There are two axes that control the planar movement of the character controller; horizontal and vertical, named `horizontalAxis` and `verticalAxis` respectively. These both take an array of input maps as values. 92 | > The format of the input maps required in arrays on scalar axes is as follows; 93 | > 94 | > `{ inputs: KeyboardEvent.code[], value: number }` 95 | > 96 | > Where `value` is the magnitude of the axis when a key in `inputs` is being pressed. 97 | 98 | + `horizontalAxis` 99 | - Default: 100 | ```js 101 | { inputs: ["KeyA", "ArrowLeft"], value: -1 }, 102 | { inputs: ["KeyD", "ArrowRight"], value: 1 }, 103 | ``` 104 | + `verticalAxis` 105 | - Default: 106 | ```js 107 | { inputs: ["KeyS", "ArrowDown"], value: -1 }, 108 | { inputs: ["KeyW", "ArrowUp"], value: 1 }, 109 | ``` 110 | - `discrete` 111 | 112 | > **Note** 113 | > 114 | > The discrete property defines single-fire actions that occur at a specific point in time. These inputs differ from the ones defined under `scalar` as they describe events that happen once at a time, rather than continuously over time. The format of input maps required here is simply an array of `KeyboardEvent.code`s. 115 | + `jump` 116 | - Default: `["Space"]` 117 | + `sprint` 118 | - Default: `["ShiftLeft", "ShiftRight"]`, 119 |
120 | 121 | > **Note** 122 | > 123 | > The default `inputMappings` object is shown below; 124 | ```js 125 | inputMappings = { 126 | scalar: { 127 | horizontalAxis: [ 128 | { inputs: ["KeyA", "ArrowLeft"], value: -1 }, 129 | { inputs: ["KeyD", "ArrowRight"], value: 1 }, 130 | ], 131 | verticalAxis: [ 132 | { inputs: ["KeyS", "ArrowDown"], value: -1 }, 133 | { inputs: ["KeyW", "ArrowUp"], value: 1 }, 134 | ], 135 | }, 136 | discrete: { 137 | jump: ["Space"], 138 | sprint: ["ShiftLeft", "ShiftRight"], 139 | }, 140 | }, 141 | ``` 142 | --------------------------------------------------------------------------------