Skip to content

Obstacle Course Game : Part 1

Video Lecture

Obstacle Course Game : Part 1 Obstacle Course Game : Part 1

Description

We will make an FPV (First Person View) game using the Rapier physics engine and Threejs.

Obstacle Course Game Part 1

<>

Lesson Scripts

./index.html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Three.js TypeScript Tutorials by Sean Bradley : https://sbcode.net/threejs</title>
  </head>

  <body>
    <div id="spinner"></div>
    <div id="instructions" style="display: none;">
      <h1>Obstacle Course</h1>
      <h2>Part 1</h2>
      <p>Get to the end and be the best</p>
      <kbd>W</kbd>&nbsp;<kbd>A</kbd>&nbsp;<kbd>S</kbd>&nbsp;<kbd>D</kbd> to move
      <br />
      <kbd>SPACE</kbd> to jump.
      <br />
      <br />
      <button id="startButton">Click To Play</button>
      <p>'Eve' model and animations from <a href="https://www.mixamo.com" target="_blank" rel="nofollow noreferrer">Mixamo</a></p>
    </div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

./src/style.css

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
body {
  overflow: hidden;
  margin: 0px;
  background-color: black;
}

#instructions {
  background-color: rgba(0, 0, 0, 0.5);
  top: 50%;
  left: 50%;
  height: 500px;
  width: 600px;
  margin-top: -250px;
  margin-left: -300px;
  position: absolute;
  font-size: 24px;
  font-family: monospace;
  color: white;
  text-align: center;
  pointer-events: none;
  user-select: none;
}

#instructions a {
  color: white;
  pointer-events: auto;
}

#instructions button {
  font-size: 24px;
  pointer-events: auto;
}

kbd {
  padding: 0px 4px;
  border: 1px solid rgb(255, 255, 255);
  border-radius: 4px;
  background: grey;
  font-size: 19px;
  color: white;
  font-family: monospace;
  font-style: normal;
}

#spinner {
  left: 50%;
  margin-left: -4em;
  font-size: 10px;
  border: 0.8em solid rgba(0, 0, 0, 1);
  border-left: 0.8em solid rgba(58, 166, 165, 1);
  animation: spin 1.1s infinite linear;
}
#spinner,
#spinner:after {
  border-radius: 50%;
  width: 8em;
  height: 8em;
  display: block;
  position: absolute;
  top: 50%;
  margin-top: -4.05em;
}

@keyframes spin {
  0% {
    transform: rotate(360deg);
  }
  100% {
    transform: rotate(0deg);
  }
}

./src/AnimationController.ts

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
import { AnimationAction, Scene } from 'three/src/Three.js'
import Keyboard from './Keyboard'
import Eve from './Eve'

export default class AnimationController {
  scene: Scene
  wait = false
  animationActions: { [key: string]: AnimationAction } = {}
  activeAction?: AnimationAction
  speed = 0
  keyboard: Keyboard
  model?: Eve

  constructor(scene: Scene, keyboard: Keyboard) {
    this.scene = scene
    this.keyboard = keyboard
  }

  async init() {
    this.model = new Eve()
    await this.model.init(this.animationActions)
    this.activeAction = this.animationActions['idle']
    this.scene.add(this.model)
  }

  setAction(action: AnimationAction) {
    if (this.activeAction != action) {
      this.activeAction?.fadeOut(0.1)
      action.reset().fadeIn(0.1).play()
      this.activeAction = action

      switch (action) {
        case this.animationActions['walk']:
          this.speed = 5.25
          break
        case this.animationActions['run']:
        case this.animationActions['jump']:
          this.speed = 16
          break
        case this.animationActions['pose']:
        case this.animationActions['idle']:
          this.speed = 0
          break
      }
    }
  }

  update(delta: number) {
    if (!this.wait) {
      let actionAssigned = false

      if (this.keyboard.keyMap['Space']) {
        this.setAction(this.animationActions['jump'])
        actionAssigned = true
        this.wait = true // blocks further actions until jump is finished
        setTimeout(() => (this.wait = false), 1200)
      }

      if (!actionAssigned && this.keyboard.keyMap['KeyW'] && this.keyboard.keyMap['ShiftLeft']) {
        this.setAction(this.animationActions['run'])
        actionAssigned = true
      }

      if (!actionAssigned && this.keyboard.keyMap['KeyW']) {
        this.setAction(this.animationActions['walk'])
        actionAssigned = true
      }

      // if (
      //   !actionAssigned &&
      //   (this.keyboard.keyMap['KeyW'] ||
      //     this.keyboard.keyMap['KeyA'] ||
      //     this.keyboard.keyMap['KeyS'] ||
      //     this.keyboard.keyMap['KeyD']) &&
      //   this.keyboard.keyMap['ShiftLeft']
      // ) {
      //   this.setAction(this.animationActions['run'])
      //   actionAssigned = true
      // }

      // if (
      //   !actionAssigned &&
      //   (this.keyboard.keyMap['KeyW'] ||
      //     this.keyboard.keyMap['KeyA'] ||
      //     this.keyboard.keyMap['KeyS'] ||
      //     this.keyboard.keyMap['KeyD'])
      // ) {
      //   this.setAction(this.animationActions['walk'])
      //   actionAssigned = true
      // }

      if (!actionAssigned && this.keyboard.keyMap['KeyQ']) {
        this.setAction(this.animationActions['pose'])
        actionAssigned = true
      }

      !actionAssigned && this.setAction(this.animationActions['idle'])
    }

    // update the Eve models animation mixer
    this.model?.update(delta)
  }
}

./src/Environment.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import { DirectionalLight, EquirectangularReflectionMapping, GridHelper, Scene, TextureLoader } from 'three'
import { RGBELoader } from 'three/addons/loaders/RGBELoader.js'
import { Lensflare, LensflareElement } from 'three/addons/objects/Lensflare.js'

export default class Environment {
  scene: Scene
  light: DirectionalLight

  constructor(scene: Scene) {
    this.scene = scene

    this.scene.add(new GridHelper(50, 50))

    this.light = new DirectionalLight(0xffffff, Math.PI)
    this.light.position.set(65.7, 19.2, 50.2)
    this.light.castShadow = true
    this.scene.add(this.light)

    // const directionalLightHelper = new CameraHelper(this.light.shadow.camera)
    // this.scene.add(directionalLightHelper)

    const textureLoader = new TextureLoader()
    const textureFlare0 = textureLoader.load('img/lensflare0.png')
    const textureFlare3 = textureLoader.load('img/lensflare3.png')

    const lensflare = new Lensflare()
    lensflare.addElement(new LensflareElement(textureFlare0, 1000, 0))
    lensflare.addElement(new LensflareElement(textureFlare3, 500, 0.2))
    lensflare.addElement(new LensflareElement(textureFlare3, 250, 0.8))
    lensflare.addElement(new LensflareElement(textureFlare3, 125, 0.6))
    lensflare.addElement(new LensflareElement(textureFlare3, 62.5, 0.4))
    this.light.add(lensflare)
  }

  async init() {
    await new RGBELoader().loadAsync('img/venice_sunset_1k.hdr').then((texture) => {
      texture.mapping = EquirectangularReflectionMapping
      this.scene.environment = texture
      this.scene.background = texture
      this.scene.backgroundBlurriness = 0.4
    })
  }
}

./src/Eve.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import { AnimationAction, AnimationMixer, Group, Mesh, AnimationUtils } from 'three'
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'

export default class Eve extends Group {
  mixer?: AnimationMixer
  glTFLoader: GLTFLoader

  constructor() {
    super()

    const dracoLoader = new DRACOLoader()
    dracoLoader.setDecoderPath('jsm/libs/draco/')

    this.glTFLoader = new GLTFLoader()
    this.glTFLoader.setDRACOLoader(dracoLoader)
  }

  async init(animationActions: { [key: string]: AnimationAction }) {
    const [eve, idle, run, jump, pose] = await Promise.all([
      this.glTFLoader.loadAsync('models/eve$@walk_compressed.glb'),
      this.glTFLoader.loadAsync('models/eve@idle.glb'),
      this.glTFLoader.loadAsync('models/eve@run.glb'),
      this.glTFLoader.loadAsync('models/eve@jump.glb'),
      this.glTFLoader.loadAsync('models/eve@pose.glb'),
    ])

    eve.scene.traverse((m) => {
      if ((m as Mesh).isMesh) {
        m.castShadow = true
      }
    })

    this.mixer = new AnimationMixer(eve.scene)
    animationActions['idle'] = this.mixer.clipAction(idle.animations[0])
    animationActions['walk'] = this.mixer.clipAction(eve.animations[0])
    //animationActions['walk'] = this.mixer.clipAction(AnimationUtils.subclip(eve.animations[0], 'walk', 0, 42))
    animationActions['run'] = this.mixer.clipAction(run.animations[0])
    //animationActions['run'] = this.mixer.clipAction(AnimationUtils.subclip(run.animations[0], 'run', 0, 17))
    // jump.animations[0].tracks = jump.animations[0].tracks.filter(function (e) {
    //   return !e.name.endsWith('.position')
    // })
    // console.log(jump.animations[0].tracks)
    animationActions['jump'] = this.mixer.clipAction(jump.animations[0])
    animationActions['pose'] = this.mixer.clipAction(pose.animations[0])

    animationActions['idle'].play()

    this.add(eve.scene)
  }

  update(delta: number) {
    this.mixer?.update(delta)
  }
}

./src/FollowCam.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import { Object3D, PerspectiveCamera, Scene, WebGLRenderer } from 'three'

export default class FollowCam {
  camera: PerspectiveCamera
  pivot = new Object3D()
  yaw = new Object3D()
  pitch = new Object3D()

  constructor(scene: Scene, camera: PerspectiveCamera, renderer: WebGLRenderer) {
    this.camera = camera

    this.yaw.position.y = 0.75

    document.addEventListener('pointerlockchange', () => {
      if (document.pointerLockElement === renderer.domElement) {
        renderer.domElement.addEventListener('mousemove', this.onDocumentMouseMove)
        renderer.domElement.addEventListener('wheel', this.onDocumentMouseWheel)
      } else {
        renderer.domElement.removeEventListener('mousemove', this.onDocumentMouseMove)
        renderer.domElement.removeEventListener('wheel', this.onDocumentMouseWheel)
      }
    })

    scene.add(this.pivot)
    this.pivot.add(this.yaw)
    this.yaw.add(this.pitch)
    this.pitch.add(camera) // adding the perspective camera to the hierarchy
  }

  onDocumentMouseMove = (e: MouseEvent) => {
    this.yaw.rotation.y -= e.movementX * 0.002
    const v = this.pitch.rotation.x - e.movementY * 0.002

    // limit range
    if (v > -1 && v < 1) {
      this.pitch.rotation.x = v
    }
  }

  onDocumentMouseWheel = (e: WheelEvent) => {
    e.preventDefault()
    const v = this.camera.position.z + e.deltaY * 0.005

    // limit range
    if (v >= 0.5 && v <= 10) {
      this.camera.position.z = v
    }
  }
}

./src/Game.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import { BoxGeometry, Mesh, MeshStandardMaterial, PerspectiveCamera, Scene, WebGLRenderer, Vector3 } from 'three'
import UI from './UI'
import Player from './Player'
import Environment from './Environment'
//import RAPIER, { World, EventQueue, RigidBodyDesc, ColliderDesc } from '@dimforge/rapier3d-compat'
//import RapierDebugRenderer from './RapierDebugRenderer'
//import { GUI } from 'three/addons/libs/lil-gui.module.min.js'

export default class Game {
  scene: Scene
  camera: PerspectiveCamera
  renderer: WebGLRenderer
  player?: Player
  //world?: World
  //rapierDebugRenderer?: RapierDebugRenderer
  //eventQueue?: EventQueue

  constructor(scene: Scene, camera: PerspectiveCamera, renderer: WebGLRenderer) {
    this.scene = scene
    this.camera = camera
    this.renderer = renderer
  }

  async init() {
    // await RAPIER.init() // This line is only needed if using the compat version
    // const gravity = new Vector3(0.0, -9.81, 0.0)

    // this.world = new World(gravity)
    // this.eventQueue = new EventQueue(true)

    // this.rapierDebugRenderer = new RapierDebugRenderer(this.scene, this.world)
    // const gui = new GUI()
    // gui.add(this.rapierDebugRenderer, 'enabled').name('Rapier Degug Renderer')

    // // the floor (using a cuboid)
    // const floorMesh = new Mesh(new BoxGeometry(50, 1, 50), new MeshStandardMaterial())
    // floorMesh.receiveShadow = true
    // floorMesh.position.y = -0.5
    // this.scene.add(floorMesh)
    //const floorBody = this.world.createRigidBody(RigidBodyDesc.fixed().setTranslation(0, -0.5, 0))
    //const floorShape = ColliderDesc.cuboid(25, 0.5, 25)
    //this.world.createCollider(floorShape, floorBody)

    this.player = new Player(this.scene, this.camera, this.renderer) //, this.world, [0, 0.1, 0])
    await this.player.init()

    const environment = new Environment(this.scene)
    await environment.init()
    // environment.light.target = this.player.followTarget

    const ui = new UI(this.renderer)
    ui.show()
  }

  update(delta: number) {
    // ;(this.world as World).timestep = Math.min(delta, 0.1)
    // this.world?.step() // this.eventQueue)
    // this.eventQueue?.drainCollisionEvents((_, __, started) => {
    //   if (started) {
    //     this.player?.setGrounded()
    //   }
    // })
    this.player?.update(delta)
    // this.rapierDebugRenderer?.update()
  }
}

./src/Keyboard.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import { WebGLRenderer } from 'three/src/Three.js'

export default class Keyboard {
  keyMap: { [key: string]: boolean } = {}

  constructor(renderer: WebGLRenderer) {
    document.addEventListener('pointerlockchange', () => {
      if (document.pointerLockElement === renderer.domElement) {
        document.addEventListener('keydown', this.onDocumentKey)
        document.addEventListener('keyup', this.onDocumentKey)
      } else {
        document.removeEventListener('keydown', this.onDocumentKey)
        document.removeEventListener('keyup', this.onDocumentKey)
      }
    })
  }

  onDocumentKey = (e: KeyboardEvent) => {
    this.keyMap[e.code] = e.type === 'keydown'
  }
}

./src/main.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import './style.css'
import { Scene, PerspectiveCamera, WebGLRenderer, Clock } from 'three'
import Stats from 'three/addons/libs/stats.module.js'
import Game from './Game'

const scene = new Scene()

const camera = new PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)
camera.position.set(0, 0, 2)

const renderer = new WebGLRenderer({ antialias: true })
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.shadowMap.enabled = true
document.body.appendChild(renderer.domElement)

window.addEventListener('resize', () => {
  camera.aspect = window.innerWidth / window.innerHeight
  camera.updateProjectionMatrix()
  renderer.setSize(window.innerWidth, window.innerHeight)
})

const stats = new Stats()
document.body.appendChild(stats.dom)

const game = new Game(scene, camera, renderer)
await game.init()

const clock = new Clock()
let delta = 0

function animate() {
  requestAnimationFrame(animate)

  delta = clock.getDelta()

  game.update(delta)

  renderer.render(scene, camera)

  stats.update()
}

animate()

./src/Player.ts

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
//import { ActiveEvents, ColliderDesc, RigidBody, RigidBodyDesc, World } from '@dimforge/rapier3d-compat'
import { Euler, Matrix4, Object3D, PerspectiveCamera, Quaternion, Scene, Vector3, WebGLRenderer } from 'three'
import AnimationController from './AnimationController'
import FollowCam from './FollowCam'
import Keyboard from './Keyboard'

export default class Player {
  scene: Scene
  //world: World
  //body: RigidBody
  animationController?: AnimationController
  vector = new Vector3()
  inputVelocity = new Vector3()
  euler = new Euler()
  quaternion = new Quaternion()
  followTarget = new Object3D() //new Mesh(new SphereGeometry(0.1), new MeshNormalMaterial())
  grounded = true
  rotationMatrix = new Matrix4()
  targetQuaternion = new Quaternion()
  followCam: FollowCam
  keyboard: Keyboard
  wait = false

  constructor(
    scene: Scene,
    camera: PerspectiveCamera,
    renderer: WebGLRenderer
    //world: World,
    //position: [number, number, number] = [0, 0, 0]
  ) {
    this.scene = scene
    //this.world = world
    this.keyboard = new Keyboard(renderer)
    this.followCam = new FollowCam(this.scene, camera, renderer)

    scene.add(this.followTarget) // the followCam will lerp towards this object3Ds world position.

    // this.body = world.createRigidBody(
    //   RigidBodyDesc.dynamic()
    //     .setTranslation(...position)
    //     .enabledRotations(false, false, false)
    //     .setLinearDamping(4)
    //     .setCanSleep(false)
    // )

    // const shape = ColliderDesc.capsule(0.5, 0.15)
    //   .setTranslation(0, 0.645, 0)
    //   .setMass(1)
    //   .setFriction(0)
    //   .setActiveEvents(ActiveEvents.COLLISION_EVENTS)

    // world.createCollider(shape, this.body)
  }

  async init() {
    this.animationController = new AnimationController(this.scene, this.keyboard)
    await this.animationController.init()
  }

  // setGrounded() {
  //   this.body.setLinearDamping(4)
  //   this.grounded = true
  //   setTimeout(() => (this.wait = false), 250)
  // }

  update(delta: number) {
    // this.inputVelocity.set(0, 0, 0)
    // if (this.grounded) {
    //   if (this.keyboard.keyMap['KeyW']) {
    //     this.inputVelocity.z = -1
    //   }
    //   if (this.keyboard.keyMap['KeyS']) {
    //     this.inputVelocity.z = 1
    //   }
    //   if (this.keyboard.keyMap['KeyA']) {
    //     this.inputVelocity.x = -1
    //   }
    //   if (this.keyboard.keyMap['KeyD']) {
    //     this.inputVelocity.x = 1
    //   }

    //   this.inputVelocity.setLength(delta * (this.animationController?.speed || 1)) // limit horizontal movement based on walking or running speed

    //   if (!this.wait && this.keyboard.keyMap['Space']) {
    //     this.wait = true
    //     this.body.setLinearDamping(0)
    //     if (this.keyboard.keyMap['ShiftLeft']) {
    //       this.inputVelocity.multiplyScalar(15) // if running, add more boost
    //     } else {
    //       this.inputVelocity.multiplyScalar(10)
    //     }
    //     this.inputVelocity.y = 5 // give jumping some height
    //     this.grounded = false
    //   }
    // }

    // // apply the followCam yaw to inputVelocity so the capsule moves forward based on cameras forward direction
    // this.euler.y = this.followCam.yaw.rotation.y
    // this.quaternion.setFromEuler(this.euler)
    // this.inputVelocity.applyQuaternion(this.quaternion)

    // // now move the capsule body based on inputVelocity
    // this.body.applyImpulse(this.inputVelocity, true)

    // // The followCam will lerp towards the followTarget position.
    // this.followTarget.position.copy(this.body.translation()) // Copy the capsules position to followTarget
    // this.followTarget.getWorldPosition(this.vector) // Put followTargets new world position into a vector
    // this.followCam.pivot.position.lerp(this.vector, delta * 10) // lerp the followCam pivot towards the vector

    // // Eve model also lerps towards the capsules position, but independently of the followCam
    // this.animationController?.model?.position.lerp(this.vector, delta * 20)

    // // Also turn Eve to face the direction of travel.
    // // First, construct a rotation matrix based on the direction from the followTarget to Eve
    // this.rotationMatrix.lookAt(
    //   this.followTarget.position,
    //   this.animationController?.model?.position as Vector3,
    //   this.animationController?.model?.up as Vector3
    // )
    // this.targetQuaternion.setFromRotationMatrix(this.rotationMatrix) // creating a quaternion to rotate Eve, since eulers can suffer from gimbal lock

    // // Next, get the distance from the Eve model to the followTarget
    //const distance = this.animationController?.model?.position.distanceTo(this.followTarget.position)

    // // If distance is higher than some espilon, and Eves quaternion isn't the same as the targetQuaternion, then rotate towards the targetQuaternion.
    // if ((distance as number) > 0.0001 && !this.animationController?.model?.quaternion.equals(this.targetQuaternion)) {
    //   this.targetQuaternion.z = 0 // so that it rotates around the Y axis
    //   this.targetQuaternion.x = 0 // so that it rotates around the Y axis
    //   this.targetQuaternion.normalize() // always normalise quaternions before use.
    //   this.animationController?.model?.quaternion.rotateTowards(this.targetQuaternion, delta * 20)
    // }

    // update which animationAction Eve should be playing
    this.animationController?.update(delta)
  }
}

./src/RapierDebugRenderer.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { Scene, LineSegments, BufferGeometry, LineBasicMaterial, BufferAttribute } from 'three'
import { World } from '@dimforge/rapier3d-compat'

export default class RapierDebugRenderer {
  mesh
  world
  enabled = true

  constructor(scene: Scene, world: World) {
    this.world = world
    this.mesh = new LineSegments(new BufferGeometry(), new LineBasicMaterial({ color: 0xffffff, vertexColors: true }))
    this.mesh.frustumCulled = false
    scene.add(this.mesh)
  }

  update() {
    if (this.enabled) {
      const { vertices, colors } = this.world.debugRender()
      this.mesh.geometry.setAttribute('position', new BufferAttribute(vertices, 3))
      this.mesh.geometry.setAttribute('color', new BufferAttribute(colors, 4))
      this.mesh.visible = true
    } else {
      this.mesh.visible = false
    }
  }
}

./src/UI.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { WebGLRenderer } from 'three'

export default class UI {
  renderer: WebGLRenderer
  instructions: HTMLDivElement

  constructor(renderer: WebGLRenderer) {
    this.renderer = renderer

    this.instructions = document.getElementById('instructions') as HTMLDivElement

    const startButton = document.getElementById('startButton') as HTMLButtonElement
    startButton.addEventListener(
      'click',
      () => {
        renderer.domElement.requestPointerLock()
      },
      false
    )

    document.addEventListener('pointerlockchange', () => {
      if (document.pointerLockElement === this.renderer.domElement) {
        this.instructions.style.display = 'none'
      } else {
        this.instructions.style.display = 'block'
      }
    })
  }

  show() {
    ;(document.getElementById('spinner') as HTMLDivElement).style.display = 'none'
    this.instructions.style.display = 'block'
  }
}

Comments