├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── screenshot01.png ├── src ├── index.html ├── index.ts ├── models │ └── Soldier.glb ├── textures │ └── grass │ │ ├── Grass_005_AmbientOcclusion.jpg │ │ ├── Grass_005_BaseColor.jpg │ │ ├── Grass_005_Normal.jpg │ │ └── Grass_005_Roughness.jpg └── utils │ ├── characterControls.ts │ └── keydisplay.ts ├── tsconfig.json └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .cache/ 3 | coverage/ 4 | dist/* 5 | !dist/index.html 6 | node_modules/ 7 | *.log 8 | 9 | # OS generated files 10 | .DS_Store 11 | .DS_Store? 12 | ._* 13 | .Spotlight-V100 14 | .Trashes 15 | ehthumbs.db 16 | Thumbs.db 17 | 18 | index.js 19 | 20 | *.module.wasm -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # threejs-rapier3d-character-terrain-movement 2 | 3 | `npm install` and `npm run start` 4 | 5 | Try the [Stackblitz](https://stackblitz.com/github/tamani-coding/threejs-rapier3d-character-terrain-movement) 6 | 7 | ![Screenshot](https://github.com/tamani-coding/threejs-rapier3d-character-terrain-movement/blob/main/screenshot01.png?raw=true) 8 | 9 | ## sources 10 | 11 | Model from: https://threejs.org/examples/#webgl_animation_skinning_blending 12 | Textures from: https://3dtextures.me/ -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "threejs-example", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "npm run build && npm run serve", 8 | "build": "webpack", 9 | "serve": "webpack serve" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "devDependencies": { 15 | "npm-run-all": "^4.1.5", 16 | "ts-loader": "^8.1.0", 17 | "typescript": "^4.2.3", 18 | "webpack": "5.71.0", 19 | "webpack-cli": "4.9.2", 20 | "webpack-dev-server": "4.7.4" 21 | }, 22 | "dependencies": { 23 | "@types/dat.gui": "^0.7.7", 24 | "@types/three": "^0.139.0", 25 | "dat.gui": "^0.7.9", 26 | "three": "^0.139.2", 27 | "@dimforge/rapier3d": "0.8.0-alpha.2" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /screenshot01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tamani-coding/threejs-rapier3d-character-terrain-movement/f5be8e84696660b930a1d7c7f6fa28d910dcb81e/screenshot01.png -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | three.js example 7 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { CharacterControls, CONTROLLER_BODY_RADIUS } from './utils/characterControls'; 2 | import { KeyDisplay } from './utils/keydisplay'; 3 | import { RigidBody, World } from '@dimforge/rapier3d'; 4 | import * as THREE from 'three'; 5 | import { AmbientLight, BoxBufferGeometry, MeshPhongMaterial } from 'three'; 6 | import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'; 7 | import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; 8 | 9 | // SCENE 10 | const scene = new THREE.Scene(); 11 | scene.background = new THREE.Color(0xa8def0); 12 | 13 | // CAMERA 14 | const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000); 15 | camera.position.y = 5; 16 | camera.position.z = 10; 17 | camera.position.x = -13; 18 | 19 | // RENDERER 20 | const renderer = new THREE.WebGLRenderer({ antialias: true }); 21 | renderer.setSize(window.innerWidth, window.innerHeight); 22 | renderer.setPixelRatio(window.devicePixelRatio); 23 | renderer.shadowMap.enabled = true 24 | 25 | // ORBIT CAMERA CONTROLS 26 | const orbitControls = new OrbitControls(camera, renderer.domElement); 27 | orbitControls.enableDamping = true 28 | orbitControls.enablePan = true 29 | orbitControls.minDistance = 5 30 | orbitControls.maxDistance = 20 31 | orbitControls.maxPolarAngle = Math.PI / 2 - 0.05 // prevent camera below ground 32 | orbitControls.minPolarAngle = Math.PI / 4 // prevent top down view 33 | orbitControls.update(); 34 | 35 | const dLight = new THREE.DirectionalLight('white', 0.6); 36 | dLight.position.x = 20; 37 | dLight.position.y = 30; 38 | dLight.castShadow = true; 39 | dLight.shadow.mapSize.width = 4096; 40 | dLight.shadow.mapSize.height = 4096; 41 | const d = 35; 42 | dLight.shadow.camera.left = - d; 43 | dLight.shadow.camera.right = d; 44 | dLight.shadow.camera.top = d; 45 | dLight.shadow.camera.bottom = - d; 46 | scene.add(dLight); 47 | 48 | const aLight = new THREE.AmbientLight('white', 0.4); 49 | scene.add(aLight); 50 | 51 | // ANIMATE 52 | document.body.appendChild(renderer.domElement); 53 | 54 | // RESIZE HANDLER 55 | function onWindowResize() { 56 | camera.aspect = window.innerWidth / window.innerHeight; 57 | camera.updateProjectionMatrix(); 58 | renderer.setSize(window.innerWidth, window.innerHeight); 59 | } 60 | window.addEventListener('resize', onWindowResize); 61 | 62 | function loadTexture(path: string): THREE.Texture { 63 | const texture = new THREE.TextureLoader().load(path); 64 | texture.wrapS = texture.wrapT = THREE.RepeatWrapping; 65 | texture.repeat.x = 10; 66 | texture.repeat.y = 10; 67 | return texture; 68 | } 69 | 70 | 71 | // MODEL WITH ANIMATIONS 72 | var characterControls: CharacterControls 73 | 74 | import('@dimforge/rapier3d').then(RAPIER => { 75 | 76 | function body(scene: THREE.Scene, world: World, 77 | bodyType: 'dynamic' | 'static' | 'kinematicPositionBased', 78 | colliderType: 'cube' | 'sphere' | 'cylinder' | 'cone', dimension: any, 79 | translation: { x: number, y: number, z: number }, 80 | rotation: { x: number, y: number, z: number }, 81 | color: string): { rigid: RigidBody, mesh: THREE.Mesh } { 82 | 83 | let bodyDesc 84 | 85 | if (bodyType === 'dynamic') { 86 | bodyDesc = RAPIER.RigidBodyDesc.dynamic(); 87 | } else if (bodyType === 'kinematicPositionBased') { 88 | bodyDesc = RAPIER.RigidBodyDesc.kinematicPositionBased(); 89 | } else if (bodyType === 'static') { 90 | bodyDesc = RAPIER.RigidBodyDesc.fixed(); 91 | bodyDesc.setCanSleep(false); 92 | } 93 | 94 | if (translation) { 95 | bodyDesc.setTranslation(translation.x, translation.y, translation.z) 96 | } 97 | if(rotation) { 98 | const q = new THREE.Quaternion().setFromEuler( 99 | new THREE.Euler( rotation.x, rotation.y, rotation.z, 'XYZ' ) 100 | ) 101 | bodyDesc.setRotation({ x: q.x, y: q.y, z: q.z, w: q.w }) 102 | } 103 | 104 | let rigidBody = world.createRigidBody(bodyDesc); 105 | 106 | let collider; 107 | if (colliderType === 'cube') { 108 | collider = RAPIER.ColliderDesc.cuboid(dimension.hx, dimension.hy, dimension.hz); 109 | } else if (colliderType === 'sphere') { 110 | collider = RAPIER.ColliderDesc.ball(dimension.radius); 111 | } else if (colliderType === 'cylinder') { 112 | collider = RAPIER.ColliderDesc.cylinder(dimension.hh, dimension.radius); 113 | } else if (colliderType === 'cone') { 114 | collider = RAPIER.ColliderDesc.cone(dimension.hh, dimension.radius); 115 | // cone center of mass is at bottom 116 | collider.centerOfMass = {x:0, y:0, z:0} 117 | } 118 | world.createCollider(collider, rigidBody.handle); 119 | 120 | let bufferGeometry; 121 | if (colliderType === 'cube') { 122 | bufferGeometry = new BoxBufferGeometry(dimension.hx * 2, dimension.hy * 2, dimension.hz * 2); 123 | } else if (colliderType === 'sphere') { 124 | bufferGeometry = new THREE.SphereBufferGeometry(dimension.radius, 32, 32); 125 | } else if (colliderType === 'cylinder') { 126 | bufferGeometry = new THREE.CylinderBufferGeometry(dimension.radius, 127 | dimension.radius, dimension.hh * 2, 32, 32); 128 | } else if (colliderType === 'cone') { 129 | bufferGeometry = new THREE.ConeBufferGeometry(dimension.radius, dimension.hh * 2, 130 | 32, 32); 131 | } 132 | 133 | const threeMesh = new THREE.Mesh(bufferGeometry, new MeshPhongMaterial({ color: color })); 134 | threeMesh.castShadow = true; 135 | threeMesh.receiveShadow = true; 136 | scene.add(threeMesh); 137 | 138 | return { rigid: rigidBody, mesh: threeMesh }; 139 | } 140 | 141 | function generateTerrain(nsubdivs: number, scale: { x: number, y: number, z: number }) { 142 | let heights: number[] = [] 143 | 144 | // three plane 145 | const threeFloor = new THREE.Mesh( 146 | new THREE.PlaneBufferGeometry(scale.x, scale.z, nsubdivs, nsubdivs), 147 | new THREE.MeshStandardMaterial({ 148 | map: loadTexture('/textures/grass/Grass_005_BaseColor.jpg'), 149 | normalMap: loadTexture('/textures/grass/Grass_005_Normal.jpg'), 150 | aoMap: loadTexture('/textures/grass/Grass_005_AmbientOcclusion.jpg'), 151 | roughnessMap: loadTexture('/textures/grass/Grass_005_Roughness.jpg'), 152 | roughness: 0.6 153 | })); 154 | threeFloor.rotateX(- Math.PI / 2); 155 | threeFloor.receiveShadow = true; 156 | threeFloor.castShadow = true; 157 | scene.add(threeFloor); 158 | 159 | // add height data to plane 160 | const vertices = threeFloor.geometry.attributes.position.array; 161 | const dx = scale.x / nsubdivs; 162 | const dy = scale.z / nsubdivs; 163 | // store height data in map column-row map 164 | const columsRows = new Map(); 165 | for (let i = 0; i < vertices.length; i += 3) { 166 | // translate into colum / row indices 167 | let row = Math.floor(Math.abs((vertices as any)[i] + (scale.x / 2)) / dx); 168 | let column = Math.floor(Math.abs((vertices as any)[i + 1] - (scale.z / 2)) / dy); 169 | // generate height for this column & row 170 | const randomHeight = Math.random(); 171 | (vertices as any)[i + 2] = scale.y * randomHeight; 172 | // store height 173 | if (!columsRows.get(column)) { 174 | columsRows.set(column, new Map()); 175 | } 176 | columsRows.get(column).set(row, randomHeight); 177 | } 178 | threeFloor.geometry.computeVertexNormals(); 179 | 180 | // store height data into column-major-order matrix array 181 | for (let i = 0; i <= nsubdivs; ++i) { 182 | for (let j = 0; j <= nsubdivs; ++j) { 183 | heights.push(columsRows.get(j).get(i)); 184 | } 185 | } 186 | 187 | let groundBodyDesc = RAPIER.RigidBodyDesc.fixed(); 188 | let groundBody = world.createRigidBody(groundBodyDesc); 189 | let groundCollider = RAPIER.ColliderDesc.heightfield( 190 | nsubdivs, nsubdivs, new Float32Array(heights), scale 191 | ); 192 | world.createCollider(groundCollider, groundBody.handle); 193 | } 194 | 195 | // Use the RAPIER module here. 196 | let gravity = { x: 0.0, y: -9.81, z: 0.0 }; 197 | let world = new RAPIER.World(gravity); 198 | 199 | // Bodys 200 | const bodys: { rigid: RigidBody, mesh: THREE.Mesh }[] = [] 201 | 202 | // Create Ground. 203 | let nsubdivs = 20; 204 | let scale = new RAPIER.Vector3(70.0, 3.0, 70.0); 205 | generateTerrain(nsubdivs, scale); 206 | 207 | const staticB = body(scene, world, 'static', 'cube', 208 | { hx: 10, hy: 0.8, hz: 10 }, { x: scale.x / 2, y: 2.5, z: 0 }, 209 | { x: 0, y: 0, z: 0.3 }, 'pink'); 210 | bodys.push(staticB); 211 | 212 | const cubeBody = body(scene, world, 'dynamic', 'cube', 213 | { hx: 0.5, hy: 0.5, hz: 0.5 }, { x: 0, y: 15, z: 0 }, 214 | { x: 0, y: 0.4, z: 0.7 }, 'orange'); 215 | bodys.push(cubeBody); 216 | 217 | const sphereBody = body(scene, world, 'dynamic', 'sphere', 218 | { radius: 0.7 }, { x: 4, y: 15, z: 2 }, 219 | { x: 0, y: 1, z: 0 }, 'blue'); 220 | bodys.push(sphereBody); 221 | 222 | const sphereBody2 = body(scene, world, 'dynamic', 'sphere', 223 | { radius: 0.7 }, { x: 0, y: 15, z: 0 }, 224 | { x: 0, y: 1, z: 0 }, 'red'); 225 | bodys.push(sphereBody2); 226 | 227 | const cylinderBody = body(scene, world, 'dynamic', 'cylinder', 228 | { hh: 1.0, radius: 0.7 }, { x: -7, y: 15, z: 8 }, 229 | { x: 0, y: 1, z: 0 }, 'green'); 230 | bodys.push(cylinderBody); 231 | 232 | const coneBody = body(scene, world, 'dynamic', 'cone', 233 | { hh: 1.0, radius: 1 }, { x: 7, y: 15, z: -8 }, 234 | { x: 0, y: 1, z: 0 }, 'purple'); 235 | bodys.push(coneBody); 236 | 237 | // character controller 238 | new GLTFLoader().load('models/Soldier.glb', function (gltf) { 239 | const model = gltf.scene; 240 | model.traverse(function (object: any) { 241 | if (object.isMesh) object.castShadow = true; 242 | }); 243 | scene.add(model); 244 | 245 | const gltfAnimations: THREE.AnimationClip[] = gltf.animations; 246 | const mixer = new THREE.AnimationMixer(model); 247 | const animationsMap: Map = new Map() 248 | gltfAnimations.filter(a => a.name != 'TPose').forEach((a: THREE.AnimationClip) => { 249 | animationsMap.set(a.name, mixer.clipAction(a)) 250 | }) 251 | 252 | 253 | // RIGID BODY 254 | let bodyDesc = RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(-1, 3, 1) 255 | let rigidBody = world.createRigidBody(bodyDesc); 256 | let dynamicCollider = RAPIER.ColliderDesc.ball(CONTROLLER_BODY_RADIUS); 257 | world.createCollider(dynamicCollider, rigidBody.handle); 258 | 259 | characterControls = new CharacterControls(model, mixer, 260 | animationsMap, orbitControls, 261 | camera, 'Idle', 262 | new RAPIER.Ray( 263 | { x: 0, y: 0, z: 0 }, 264 | { x: 0, y: -1, z: 0} 265 | ), rigidBody) 266 | }); 267 | 268 | const clock = new THREE.Clock(); 269 | // Game loop. 270 | let gameLoop = () => { 271 | let deltaTime = clock.getDelta(); 272 | 273 | if (characterControls) { 274 | characterControls.update(world, deltaTime, keysPressed); 275 | } 276 | 277 | // Step the simulation forward. 278 | world.step(); 279 | // update 3d world with physical world 280 | bodys.forEach(body => { 281 | let position = body.rigid.translation(); 282 | let rotation = body.rigid.rotation(); 283 | 284 | body.mesh.position.x = position.x 285 | body.mesh.position.y = position.y 286 | body.mesh.position.z = position.z 287 | 288 | body.mesh.setRotationFromQuaternion( 289 | new THREE.Quaternion(rotation.x, 290 | rotation.y, 291 | rotation.z, 292 | rotation.w)); 293 | }); 294 | 295 | orbitControls.update() 296 | renderer.render(scene, camera); 297 | 298 | setTimeout(gameLoop, 16); 299 | }; 300 | 301 | gameLoop(); 302 | }) 303 | 304 | 305 | const keysPressed: any = {} 306 | const keyDisplayQueue = new KeyDisplay(); 307 | document.addEventListener('keydown', (event) => { 308 | keyDisplayQueue.down(event.key) 309 | if (event.shiftKey && characterControls) { 310 | characterControls.switchRunToggle() 311 | } 312 | keysPressed[event.key.toLowerCase()] = true 313 | }, false); 314 | document.addEventListener('keyup', (event) => { 315 | keyDisplayQueue.up(event.key); 316 | keysPressed[event.key.toLowerCase()] = false 317 | }, false); -------------------------------------------------------------------------------- /src/models/Soldier.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tamani-coding/threejs-rapier3d-character-terrain-movement/f5be8e84696660b930a1d7c7f6fa28d910dcb81e/src/models/Soldier.glb -------------------------------------------------------------------------------- /src/textures/grass/Grass_005_AmbientOcclusion.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tamani-coding/threejs-rapier3d-character-terrain-movement/f5be8e84696660b930a1d7c7f6fa28d910dcb81e/src/textures/grass/Grass_005_AmbientOcclusion.jpg -------------------------------------------------------------------------------- /src/textures/grass/Grass_005_BaseColor.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tamani-coding/threejs-rapier3d-character-terrain-movement/f5be8e84696660b930a1d7c7f6fa28d910dcb81e/src/textures/grass/Grass_005_BaseColor.jpg -------------------------------------------------------------------------------- /src/textures/grass/Grass_005_Normal.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tamani-coding/threejs-rapier3d-character-terrain-movement/f5be8e84696660b930a1d7c7f6fa28d910dcb81e/src/textures/grass/Grass_005_Normal.jpg -------------------------------------------------------------------------------- /src/textures/grass/Grass_005_Roughness.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tamani-coding/threejs-rapier3d-character-terrain-movement/f5be8e84696660b930a1d7c7f6fa28d910dcb81e/src/textures/grass/Grass_005_Roughness.jpg -------------------------------------------------------------------------------- /src/utils/characterControls.ts: -------------------------------------------------------------------------------- 1 | import { Ray, RigidBody, World } from '@dimforge/rapier3d'; 2 | import * as THREE from 'three' 3 | import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' 4 | import { A, D, DIRECTIONS, S, W } from './keydisplay' 5 | 6 | export const CONTROLLER_BODY_RADIUS = 0.28; 7 | 8 | export class CharacterControls { 9 | 10 | model: THREE.Group 11 | mixer: THREE.AnimationMixer 12 | animationsMap: Map = new Map() // Walk, Run, Idle 13 | orbitControl: OrbitControls 14 | camera: THREE.Camera 15 | 16 | // state 17 | toggleRun: boolean = true 18 | currentAction: string 19 | 20 | // temporary data 21 | walkDirection = new THREE.Vector3() 22 | rotateAngle = new THREE.Vector3(0, 1, 0) 23 | rotateQuarternion: THREE.Quaternion = new THREE.Quaternion() 24 | cameraTarget = new THREE.Vector3() 25 | storedFall = 0 26 | 27 | // constants 28 | fadeDuration: number = 0.2 29 | runVelocity = 5 30 | walkVelocity = 2 31 | 32 | ray: Ray 33 | rigidBody: RigidBody 34 | lerp = (x: number, y: number, a: number) => x * (1 - a) + y * a; 35 | 36 | constructor(model: THREE.Group, 37 | mixer: THREE.AnimationMixer, animationsMap: Map, 38 | orbitControl: OrbitControls, camera: THREE.Camera, 39 | currentAction: string, 40 | ray: Ray, rigidBody: RigidBody) { 41 | this.model = model 42 | this.mixer = mixer 43 | this.animationsMap = animationsMap 44 | this.currentAction = currentAction 45 | this.animationsMap.forEach((value, key) => { 46 | if (key == currentAction) { 47 | value.play() 48 | } 49 | }) 50 | 51 | this.ray = ray 52 | this.rigidBody = rigidBody 53 | 54 | this.orbitControl = orbitControl 55 | this.camera = camera 56 | this.updateCameraTarget(new THREE.Vector3(0,1,5)) 57 | } 58 | 59 | public switchRunToggle() { 60 | this.toggleRun = !this.toggleRun 61 | } 62 | 63 | public update(world: World, delta: number, keysPressed: any) { 64 | const directionPressed = DIRECTIONS.some(key => keysPressed[key] == true) 65 | 66 | var play = ''; 67 | if (directionPressed && this.toggleRun) { 68 | play = 'Run' 69 | } else if (directionPressed) { 70 | play = 'Walk' 71 | } else { 72 | play = 'Idle' 73 | } 74 | 75 | if (this.currentAction != play) { 76 | const toPlay = this.animationsMap.get(play) 77 | const current = this.animationsMap.get(this.currentAction) 78 | 79 | current.fadeOut(this.fadeDuration) 80 | toPlay.reset().fadeIn(this.fadeDuration).play(); 81 | 82 | this.currentAction = play 83 | } 84 | 85 | this.mixer.update(delta) 86 | 87 | this.walkDirection.x = this.walkDirection.y = this.walkDirection.z = 0 88 | 89 | let velocity = 0 90 | if (this.currentAction == 'Run' || this.currentAction == 'Walk') { 91 | // calculate towards camera direction 92 | var angleYCameraDirection = Math.atan2( 93 | (this.camera.position.x - this.model.position.x), 94 | (this.camera.position.z - this.model.position.z)) 95 | // diagonal movement angle offset 96 | var directionOffset = this.directionOffset(keysPressed) 97 | 98 | // rotate model 99 | this.rotateQuarternion.setFromAxisAngle(this.rotateAngle, angleYCameraDirection + directionOffset) 100 | this.model.quaternion.rotateTowards(this.rotateQuarternion, 0.2) 101 | 102 | // calculate direction 103 | this.camera.getWorldDirection(this.walkDirection) 104 | this.walkDirection.y = 0 105 | this.walkDirection.normalize() 106 | this.walkDirection.applyAxisAngle(this.rotateAngle, directionOffset) 107 | 108 | // run/walk velocity 109 | velocity = this.currentAction == 'Run' ? this.runVelocity : this.walkVelocity 110 | } 111 | 112 | const translation = this.rigidBody.translation(); 113 | if (translation.y < -1) { 114 | // don't fall below ground 115 | this.rigidBody.setNextKinematicTranslation( { 116 | x: 0, 117 | y: 10, 118 | z: 0 119 | }); 120 | } else { 121 | const cameraPositionOffset = this.camera.position.sub(this.model.position); 122 | // update model and camera 123 | this.model.position.x = translation.x 124 | this.model.position.y = translation.y 125 | this.model.position.z = translation.z 126 | this.updateCameraTarget(cameraPositionOffset) 127 | 128 | this.walkDirection.y += this.lerp(this.storedFall, -9.81 * delta, 0.10) 129 | this.storedFall = this.walkDirection.y 130 | this.ray.origin.x = translation.x 131 | this.ray.origin.y = translation.y 132 | this.ray.origin.z = translation.z 133 | let hit = world.castRay(this.ray, 0.5, false, 0xfffffffff); 134 | if (hit) { 135 | const point = this.ray.pointAt(hit.toi); 136 | let diff = translation.y - ( point.y + CONTROLLER_BODY_RADIUS); 137 | if (diff < 0.0) { 138 | this.storedFall = 0 139 | this.walkDirection.y = this.lerp(0, Math.abs(diff), 0.5) 140 | } 141 | } 142 | 143 | this.walkDirection.x = this.walkDirection.x * velocity * delta 144 | this.walkDirection.z = this.walkDirection.z * velocity * delta 145 | 146 | this.rigidBody.setNextKinematicTranslation( { 147 | x: translation.x + this.walkDirection.x, 148 | y: translation.y + this.walkDirection.y, 149 | z: translation.z + this.walkDirection.z 150 | }); 151 | } 152 | } 153 | 154 | private updateCameraTarget(offset: THREE.Vector3) { 155 | // move camera 156 | const rigidTranslation = this.rigidBody.translation(); 157 | this.camera.position.x = rigidTranslation.x + offset.x 158 | this.camera.position.y = rigidTranslation.y + offset.y 159 | this.camera.position.z = rigidTranslation.z + offset.z 160 | 161 | // update camera target 162 | this.cameraTarget.x = rigidTranslation.x 163 | this.cameraTarget.y = rigidTranslation.y + 1 164 | this.cameraTarget.z = rigidTranslation.z 165 | this.orbitControl.target = this.cameraTarget 166 | } 167 | 168 | private directionOffset(keysPressed: any) { 169 | var directionOffset = 0 // w 170 | 171 | if (keysPressed[W]) { 172 | if (keysPressed[A]) { 173 | directionOffset = Math.PI / 4 // w+a 174 | } else if (keysPressed[D]) { 175 | directionOffset = - Math.PI / 4 // w+d 176 | } 177 | } else if (keysPressed[S]) { 178 | if (keysPressed[A]) { 179 | directionOffset = Math.PI / 4 + Math.PI / 2 // s+a 180 | } else if (keysPressed[D]) { 181 | directionOffset = -Math.PI / 4 - Math.PI / 2 // s+d 182 | } else { 183 | directionOffset = Math.PI // s 184 | } 185 | } else if (keysPressed[A]) { 186 | directionOffset = Math.PI / 2 // a 187 | } else if (keysPressed[D]) { 188 | directionOffset = - Math.PI / 2 // d 189 | } 190 | 191 | return directionOffset 192 | } 193 | 194 | } -------------------------------------------------------------------------------- /src/utils/keydisplay.ts: -------------------------------------------------------------------------------- 1 | export const W = 'w' 2 | export const A = 'a' 3 | export const S = 's' 4 | export const D = 'd' 5 | export const SHIFT = 'shift' 6 | export const DIRECTIONS = [W, A, S, D] 7 | 8 | export class KeyDisplay { 9 | 10 | map: Map = new Map() 11 | 12 | constructor() { 13 | const w: HTMLDivElement = document.createElement("div") 14 | const a: HTMLDivElement = document.createElement("div") 15 | const s: HTMLDivElement = document.createElement("div") 16 | const d: HTMLDivElement = document.createElement("div") 17 | const shift: HTMLDivElement = document.createElement("div") 18 | 19 | this.map.set(W, w) 20 | this.map.set(A, a) 21 | this.map.set(S, s) 22 | this.map.set(D, d) 23 | this.map.set(SHIFT, shift) 24 | 25 | this.map.forEach( (v, k) => { 26 | v.style.color = 'blue' 27 | v.style.fontSize = '50px' 28 | v.style.fontWeight = '800' 29 | v.style.position = 'absolute' 30 | v.textContent = k 31 | }) 32 | 33 | this.updatePosition() 34 | 35 | this.map.forEach( (v, _) => { 36 | document.body.append(v) 37 | }) 38 | } 39 | 40 | public updatePosition() { 41 | this.map.get(W).style.top = `${window.innerHeight - 150}px` 42 | this.map.get(A).style.top = `${window.innerHeight - 100}px` 43 | this.map.get(S).style.top = `${window.innerHeight - 100}px` 44 | this.map.get(D).style.top = `${window.innerHeight - 100}px` 45 | this.map.get(SHIFT).style.top = `${window.innerHeight - 100}px` 46 | 47 | this.map.get(W).style.left = `${300}px` 48 | this.map.get(A).style.left = `${200}px` 49 | this.map.get(S).style.left = `${300}px` 50 | this.map.get(D).style.left = `${400}px` 51 | this.map.get(SHIFT).style.left = `${50}px` 52 | } 53 | 54 | public down (key: string) { 55 | if (this.map.get(key.toLowerCase())) { 56 | this.map.get(key.toLowerCase()).style.color = 'red' 57 | } 58 | } 59 | 60 | public up (key: string) { 61 | if (this.map.get(key.toLowerCase())) { 62 | this.map.get(key.toLowerCase()).style.color = 'blue' 63 | } 64 | } 65 | 66 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "sourceMap": true, 5 | "noImplicitAny": true, 6 | "module": "CommonJS", 7 | "target": "es5", 8 | "allowJs": true 9 | } 10 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const src = path.resolve(__dirname, 'src'); 4 | 5 | module.exports = { 6 | mode: 'development', 7 | entry: './src/index.ts', 8 | devtool: 'inline-source-map', 9 | module: { 10 | rules: [ 11 | { 12 | test: /\.tsx?$/, 13 | use: 'ts-loader', 14 | exclude: /node_modules/ 15 | } 16 | ] 17 | }, 18 | resolve: { 19 | extensions: ['.tsx', '.ts', '.js'] 20 | }, 21 | output: { 22 | filename: 'index.js', 23 | path: src 24 | }, 25 | devServer: { 26 | static: src, 27 | }, 28 | experiments: { 29 | asyncWebAssembly: true, 30 | }, 31 | } --------------------------------------------------------------------------------