Skip to content

Obstacle Course Game : Part 2

Video Lecture

Obstacle Course Game : Part 2 Obstacle Course Game : Part 2

Description

We add some platforms, spinners, pendulums, start and finish platforms to the game example.

Each new type of game object will have a different problem to solve.

Obstacle Course Game Part 2

<>

Lesson Scripts

Resources

There is one extra image, and two extra models to download for this lesson.

Download the zip file below, and extract the contents into your ./public/ folder.

obstacle-course-part2.zip

The zip already contains the folder structure for /img/ and models/.

./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
26
27
28
29
30
<!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="timeDisplay" style="display: none">0</div>
    <div id="levelCompleted" style="display: none">Level Completed. Well Done!</div>
    <div id="instructions" style="display: none">
      <h1>Obstacle Course</h1>
      <h2>Part 2</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
 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
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);
  }
}

#timeDisplay {
  font-family: monospace;
  position: absolute;
  top: 50%;
  left: 50%;
  height: 16vw;
  width: 16vw;
  margin-left: -8vw;
  margin-top: -50vh;
  font-size: 16vh;
  color: white;
  text-shadow: 2px 2px 0 #000;
  text-align: center;
  display: none;
}

#levelCompleted {
  font-family: monospace;
  position: absolute;
  top: 50%;
  left: 50%;
  height: 32vw;
  width: 32vw;
  margin-left: -16vw;
  margin-top: -30vh;
  font-size: 4vh;
  color: white;
  text-shadow: 2px 2px 0 #000;
  text-align: center;
}

./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
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
    }
  }

  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['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
    if (this.activeAction === this.animationActions['walk']) {
      this.model?.update(delta * 2)
    } else {
      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
import { DirectionalLight, EquirectangularReflectionMapping, 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.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
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, 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@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(AnimationUtils.subclip(eve.animations[0], 'walk', 0, 42))
    // 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/Finish.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
import { Scene, Object3D, Mesh, MeshStandardMaterial, Texture, TextureLoader, RepeatWrapping, CylinderGeometry, MeshPhongMaterial, DoubleSide } from 'three'
import { World, RigidBody, RigidBodyDesc, ColliderDesc } from '@dimforge/rapier3d-compat'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'

export default class Finish {
  dynamicBody?: [Object3D, RigidBody]
  material = new MeshStandardMaterial()
  texture: Texture
  handle = -1

  constructor(scene: Scene, world: World, position: [number, number, number]) {
    this.texture = new TextureLoader().load('img/finish.png', (texture) => {
      texture.repeat.x = 2
      texture.wrapS = RepeatWrapping
      texture.flipY = true
    })

    const banner = new Mesh(new CylinderGeometry(3.4, 3.4, 2, 12, 1, true), new MeshPhongMaterial({ transparent: true, opacity: 0.75, map: this.texture, side: DoubleSide }))
    banner.position.set(...position)
    banner.position.y += 3
    scene.add(banner)

    new GLTFLoader().load('models/finish.glb', (gltf) => {
      const mesh = gltf.scene.getObjectByName('Cylinder') as Mesh
      mesh.receiveShadow = true
      scene.add(mesh)

      this.material = mesh.material as MeshStandardMaterial

      const body = world.createRigidBody(RigidBodyDesc.fixed().setTranslation(...position))
      this.handle = body.handle

      const points = new Float32Array(mesh.geometry.attributes.position.array)
      const shape = ColliderDesc.convexHull(points) as ColliderDesc

      world.createCollider(shape, body)

      mesh.position.copy(body.translation())
      mesh.quaternion.copy(body.rotation())

      setInterval(() => {
        ;(this.material.map as Texture).rotation += Math.PI
      }, 500)
    })
  }

  update(delta: number) {
    this.texture.offset.x += delta / 3
  }
}

./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
 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
import { PerspectiveCamera, Scene, WebGLRenderer, Vector3 } from 'three'
import UI from './UI'
import Player from './Player'
import Environment from './Environment'
import RAPIER, { World, EventQueue } from '@dimforge/rapier3d-compat'
import RapierDebugRenderer from './RapierDebugRenderer'
import { GUI } from 'three/addons/libs/lil-gui.module.min.js'
import Start from './Start'
import Platform from './Platform'
import Finish from './Finish'
import Spinner from './Spinner'
import Pendulum from './Pendulum'

export default class Game {
  scene: Scene
  camera: PerspectiveCamera
  renderer: WebGLRenderer
  ui: UI
  player?: Player
  world?: World
  rapierDebugRenderer?: RapierDebugRenderer
  eventQueue?: EventQueue
  finish?: Finish
  spinners: Spinner[] = []
  pendulums: Pendulum[] = []

  constructor(scene: Scene, camera: PerspectiveCamera, renderer: WebGLRenderer) {
    this.scene = scene
    this.camera = camera
    this.renderer = renderer
    this.ui = new UI(this.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)
    this.rapierDebugRenderer.enabled = false
    const gui = new GUI()
    gui.add(this.rapierDebugRenderer, 'enabled').name('Rapier Degug Renderer')

    new Start(this.scene, this.world, [0, -0.5, 0])

    // new Platform(this.scene, this.world, [1, 0.1, 2], [0, 0, 6])

    // new Platform(this.scene, this.world, [2.5, 0.1, 1], [3, 0.25, 6])

    // new Platform(this.scene, this.world, [2, 0.1, 1], [6, 1, 6])

    // new Platform(this.scene, this.world, [0.25, 0.1, 4.5], [6, 2, 2.25])

    // new Platform(this.scene, this.world, [4, 0.1, 5], [6, 2, -3])

    // this.spinners.push(new Spinner(this.scene, this.world, [6, 2.8, -3]))

    // new Platform(this.scene, this.world, [1, 0.1, 2], [6.25, 2.5, -7.5])

    // new Platform(this.scene, this.world, [4, 0.1, 4], [2.5, 3, -8])

    // this.spinners.push(new Spinner(this.scene, this.world, [2.5, 3.8, -8]))

    // new Platform(this.scene, this.world, [1, 0.1, 2.75], [1.5, 3.75, -3.25], [-Math.PI / 8, 0, 0])

    // new Platform(this.scene, this.world, [6, 0.1, 1], [-1, 4.5, -1])

    // this.pendulums.push(new Pendulum(this.scene, this.world, [0, 8, -1]))

    // this.pendulums.push(new Pendulum(this.scene, this.world, [-2, 8, -1]))

    // new Platform(this.scene, this.world, [1.5, 0.1, 8], [-5.5, 4.5, 4.5], [0, 0, -Math.PI / 8])

    // this.pendulums.push(new Pendulum(this.scene, this.world, [-5, 8, 2.5], Math.PI / 2))

    // this.pendulums.push(new Pendulum(this.scene, this.world, [-5, 8, 5], Math.PI / 2))

    // this.finish = new Finish(this.scene, this.world, [0, 4.0, 10])

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

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

    this.ui.show()
  }

  update(delta: number) {
    this.spinners.forEach((s) => {
      s.update(delta)
    })
    ;(this.world as World).timestep = Math.min(delta, 0.1)
    this.world?.step(this.eventQueue)
    this.eventQueue?.drainCollisionEvents((handle1, handle2, started) => {
      if (started) {
        // if we land on the finish platform
        if ([handle1, handle2].includes(this.finish?.handle as number)) {
          this.ui.showLevelCompleted()
        }
      }

      // exclude spinner collisions from player grounded check
      let hitSpinner = false
      this.spinners.forEach((s) => {
        // each spinner tracks a handle for one body only
        if ([handle1, handle2].includes(s.handle)) {
          hitSpinner = true
        }
      })

      // exclude pendulums collisions from player grounded check
      let hitPendulum = false
      this.pendulums.forEach((p) => {
        // each pendulum tracks the handles of the two lowest ball bodies
        if (p.handles.some((h) => [handle1, handle2].includes(h))) {
          hitPendulum = true
        }
      })

      if (!hitSpinner && !hitPendulum) {
        this.player?.setGrounded(started)
      }
    })
    this.player?.update(delta)
    this.finish?.update(delta)
    this.pendulums.forEach((p) => {
      p.update()
    })
    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/Pendulum.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 { ColliderDesc, JointData, RigidBody, RigidBodyDesc, RigidBodyType, Vector3, World } from '@dimforge/rapier3d-compat'
import { Euler, Mesh, MeshStandardMaterial, Object3D, Quaternion, Scene, SphereGeometry } from 'three'

export default class Pendulum {
  dynamicBodies: [Object3D, RigidBody][] = []
  handles = [-1, -1]

  constructor(scene: Scene, world: World, position: [number, number, number], rotationY: number = 0) {
    const parents = []

    for (let i = 0; i < 4; i++) {
      const mesh = new Mesh(new SphereGeometry(0.4), new MeshStandardMaterial())
      mesh.position.set(position[0], position[1], i + position[2])
      mesh.castShadow = true
      scene.add(mesh)

      let rigidBodyType

      if (i == 0) {
        rigidBodyType = RigidBodyType.Fixed
      } else {
        rigidBodyType = RigidBodyType.Dynamic
      }

      const body = world.createRigidBody(
        new RigidBodyDesc(rigidBodyType).setTranslation(position[0], position[1], i + position[2]).setRotation(new Quaternion().setFromEuler(new Euler(0, rotationY, 0)))
      )
      let colliderDesc = ColliderDesc.ball(0.4).setMass(1)

      if (i >= 2) {
        // will check for collisions with lowest 2 hanging balls in game.ts update loop
        this.handles.push(body.handle)
      }

      world.createCollider(colliderDesc, body)

      if (i > 0) {
        let parent = parents[parents.length - 1]
        let params = JointData.spherical(new Vector3(0.0, 0.0, 0.0), new Vector3(0.0, 0.0, -1))
        world.createImpulseJoint(params, parent, body, true)
      }

      parents.push(body)

      this.dynamicBodies.push([mesh, body])
    }
  }

  update() {
    for (let i = 0, n = this.dynamicBodies.length; i < n; i++) {
      this.dynamicBodies[i][0].position.copy(this.dynamicBodies[i][1].translation())
      this.dynamicBodies[i][0].quaternion.copy(this.dynamicBodies[i][1].rotation())
    }
  }
}

./src/Platform.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
import { ColliderDesc, RigidBodyDesc, World } from '@dimforge/rapier3d-compat'
import { BoxGeometry, Euler, Mesh, MeshStandardMaterial, Quaternion, Scene } from 'three'

export default class Platform {
  constructor(scene: Scene, world: World, size: [number, number, number], position: [number, number, number], rotation: [number, number, number] = [0, 0, 0]) {
    const mesh = new Mesh(new BoxGeometry(...size), new MeshStandardMaterial())
    mesh.castShadow = true
    mesh.receiveShadow = true
    scene.add(mesh)

    const body = world.createRigidBody(
      RigidBodyDesc.fixed()
        .setTranslation(...position)
        .setRotation(new Quaternion().setFromEuler(new Euler(...rotation)))
    )

    const shape = ColliderDesc.cuboid(size[0] / 2, size[1] / 2, size[2] / 2)

    world.createCollider(shape, body)

    mesh.position.copy(body.translation())
    mesh.quaternion.copy(body.rotation())
  }
}

./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
137
138
139
140
141
142
143
144
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'
import UI from './UI'

export default class Player {
  scene: Scene
  world: World
  ui: UI
  body: RigidBody
  animationController?: AnimationController
  vector = new Vector3()
  inputVelocity = new Vector3()
  euler = new Euler()
  quaternion = new Quaternion()
  followTarget = new Object3D()
  grounded = false
  rotationMatrix = new Matrix4()
  targetQuaternion = new Quaternion()
  followCam: FollowCam
  keyboard: Keyboard
  wait = false
  handle = -1

  constructor(scene: Scene, camera: PerspectiveCamera, renderer: WebGLRenderer, world: World, position: [number, number, number] = [0, 0, 0], ui: UI) {
    this.scene = scene
    this.world = world
    this.ui = ui
    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)
        .setCanSleep(false)
    )
    this.handle = this.body.handle

    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(grounded: boolean) {
    if (grounded != this.grounded) {
      // do this only if it was changed
      this.grounded = grounded
      if (grounded) {
        this.body.setLinearDamping(4)
        setTimeout(() => {
          this.wait = false
        }, 250)
      } else {
        this.body.setLinearDamping(0)
      }
    }
  }

  reset() {
    this.body.setLinvel(new Vector3(0, 0, 0), true)
    this.body.setTranslation(new Vector3(0, 1, 0), true)
    this.ui.reset()
  }

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

      this.inputVelocity.setLength(delta * limit) // limits horizontal movement

      if (!this.wait && this.keyboard.keyMap['Space']) {
        this.wait = true
        this.inputVelocity.y = 5 // give jumping some height
      }
    }

    // // 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)

    // if out of bounds
    if (this.body.translation().y < -3) {
      this.reset()
    }

    // // 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/Spinner.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
import { ColliderDesc, RigidBody, RigidBodyDesc, World } from '@dimforge/rapier3d-compat'
import { CylinderGeometry, Euler, Group, Mesh, MeshStandardMaterial, Quaternion, Scene } from 'three'

export default class Spinner {
  group: Group
  body: RigidBody
  handle = -1

  constructor(scene: Scene, world: World, position: [number, number, number]) {
    this.group = new Group()
    this.group.position.set(...position)
    scene.add(this.group)

    const verticleBar = new Mesh(new CylinderGeometry(0.25, 0.25, 1.5), new MeshStandardMaterial())
    verticleBar.castShadow = true
    this.group.add(verticleBar)

    const horizontalBar = new Mesh(new CylinderGeometry(0.25, 0.25, 4), new MeshStandardMaterial())
    horizontalBar.rotateX(-Math.PI / 2)
    horizontalBar.castShadow = true
    this.group.add(horizontalBar)

    this.body = world.createRigidBody(RigidBodyDesc.kinematicPositionBased().setTranslation(...position))
    this.handle = this.body.handle

    const shape = ColliderDesc.cylinder(2, 0.25).setRotation(new Quaternion().setFromEuler(new Euler(-Math.PI / 2, 0, 0)))

    world.createCollider(shape, this.body)
  }

  update(delta: number) {
    this.group.rotation.y += delta

    // with a kinematicPositionBased body, we can just copy the Threejs transform
    this.body.setNextKinematicRotation(this.group.quaternion)
  }
}

./src/Start.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 { Scene, Mesh, MeshStandardMaterial, Texture } from 'three'
import { World, RigidBodyDesc, ColliderDesc } from '@dimforge/rapier3d-compat'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'

export default class Start {
  material = new MeshStandardMaterial()

  constructor(scene: Scene, world: World, position: [number, number, number]) {
    new GLTFLoader().load('models/start.glb', (gltf) => {
      const mesh = gltf.scene.getObjectByName('Cylinder') as Mesh
      mesh.receiveShadow = true
      scene.add(mesh)

      this.material = mesh.material as MeshStandardMaterial
      this.material.map?.center.set(0.1034, 0) // fixes slightly offset texture

      const body = world.createRigidBody(RigidBodyDesc.fixed().setTranslation(...position))

      //const shape = ColliderDesc.cylinder(0.15, 3.7)

      const points = new Float32Array(mesh.geometry.attributes.position.array)
      const shape = ColliderDesc.convexHull(points) as ColliderDesc

      world.createCollider(shape, body)

      mesh.position.copy(body.translation())
      mesh.quaternion.copy(body.rotation())

      setInterval(() => {
        ;(this.material.map as Texture).rotation += Math.PI
      }, 500)
    })
  }
}

./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
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
import { WebGLRenderer } from 'three'

export default class UI {
  renderer: WebGLRenderer
  instructions: HTMLDivElement
  timeDisplay: HTMLDivElement
  levelCompleted: HTMLDivElement
  interval = -1
  time = 0

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

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

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

    this.levelCompleted = document.getElementById('levelCompleted') 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.levelCompleted.style.display = 'none'

        this.instructions.style.display = 'none'

        this.timeDisplay.style.display = 'block'

        this.interval = setInterval(() => {
          this.time += 1
          this.timeDisplay.innerText = this.time.toString()
        }, 1000)
      } else {
        this.instructions.style.display = 'block'

        this.timeDisplay.style.display = 'none'
        clearInterval(this.interval)
      }
    })
  }

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

  reset() {
    clearInterval(this.interval)

    this.levelCompleted.style.display = 'none'

    this.time = 0
    this.timeDisplay.innerText = this.time.toString()

    this.interval = setInterval(() => {
      this.time += 1
      this.timeDisplay.innerText = this.time.toString()
    }, 1000)
  }

  showLevelCompleted() {
    clearInterval(this.interval)

    this.levelCompleted.style.display = 'block'
  }
}

Rigid-body type

Comments