Mon studio de développement d'expériences numériques.
Avec de vrais morceaux de codes, je conçoit des choses utiles - parfois et
d'autres moins inutiles - souvent.

Découvrez moi, mes articles, ou mes réalisations.

Créer un jeu vidéo - Journal de bord partie 2

Créer un jeu vidéo - Journal de bord partie 2

gamedev, javascript, threejs Publié le lun. 13 janv. 2025

Dans le premier article sur la création d’un jeu vidéo on a posé l’idée du projet, les technologies utilisées et on vu un exemple rapide d’une application Three.js. Mais comme évoqué, le code était un peu brouillon et pas très structuré. Dans cette deuxième partie, on va voir comment créer une bonne base de code pour notre jeu.

Structurer le projet

Pour commencer, on va créer une structure de projet simple. On va séparer notre code en plusieurs fichiers pour plus de clarté. On va voir en détail chaque fichier.

src/
├── core/
   ├── Game.ts # Classe principale du jeu
   ├── Rendering.ts # Gestion du rendu
   ├── Time.ts # Gestion du temps
   ├── View.ts # Gestion de la vue
   ├── Viewport.ts # Gestion du viewport, càd la taille de la fenêtre
   ├── World.ts # Classe principale de la scène

Un jeu 3D, c’est une scène (World), rendu (Rendering) dans une fenêtre d’affichage (Viewport) avec une caméra (View) 60 fois par seconde (Time). Le tout est orchestré par une classe principale (Game).

Game.ts

Comme évoqué Game sera la classe principale de notre jeu. Elle va gérer tous les autres aspects du jeu et donc contenir toutes les informations nécessaires. Ces informations seront également nécessaires pour chaque autre classe, et ainsi chaque classe doit pouvoir accéder à Game. Pour ce faire, on peut envisager deux approches :

La première approche est plus simple à mettre en place, mais peut vite devenir lourde à gérer si on a beaucoup de classes. La deuxième approche est plus élégante, mais est souvent décriée pour son utilisation abusive.

Personnellement, je ne suis pas un adèpte du singleton, mais pour le coup, je trouve son utilisation justifiée.

Ce qui donnerait quelque chose comme ça pour src/core/Game.ts :

import Time from './Time.js'
import Viewport from './Viewport.js'
import Rendering from './Rendering.js'
import View from './View.js'
import World from './World.js'

export default class Game {
  static _instance: Game // Instance du singleton

  canvas: HTMLCanvasElement | null = null // Canvas du jeu

  // Les différentes classes du jeu
  world: World
  time: Time
  viewport: Viewport
  view: View
  rendering: Rendering

  constructor(canvas : HTMLCanvasElement | null) {
    if (Game._instance) {
      throw new Error('Game is a singleton class')
    }

    Game._instance = this

    this.canvas = canvas

    this.world = new World()
    this.time = new Time()
    this.viewport = new Viewport()
    this.view = new View()
    this.rendering = new Rendering()
  }

  // Méthode pour récupérer l'instance de Game
  static getInstance(canvas : HTMLCanvasElement | null = null): Game {
    if (Game._instance) {
      return Game._instance
    }

    return new Game(canvas)
  }

  // Méthode pour démarrer le jeu
  start (): void {
    this.time.tick()
  }
}

World.ts

La classe World va contenir tout simplement la scène, elle n’a pas besoin de grand-chose pour le moment.

import { Scene } from 'three'

export default class World {
  scene: Scene = new Scene()
}

View.ts

La classe View va gérer la caméra. Elle va l’initialiser et la mettre à jour en cas de changement de taille de la fenêtre. Elle va également gérer le type de caméra (projection orthographique, perspective, etc.). Ainsi que la profondeur de champ, le champ de vision, etc.

import { PerspectiveCamera } from 'three'
import Game from './Game.js'

// On définit un type pour les options de la caméra
export type CameraOptions = {
  fov: number,
  near: number,
  far: number
}

export default class View {
  camera: PerspectiveCamera
  game: Game = Game.getInstance()

  // On peut passer des options pour la caméra (champ de vision, distance de vue courte et longue.)
  // Il existe d'autres options, mais on va se contenter de celles-ci pour le moment.
  constructor({ fov, near, far }: CameraOptions = { fov: 75, near: 0.1, far: 100 }) {
    this.camera = new PerspectiveCamera(
      fov, // Champ de vision
      this.game.viewport.sizes.width / this.game.viewport.sizes.height, // Ratio de l'écran
      near, // Distance de vue courte
      far // Distance de vue longue
    )
  }

  // Méthode pour mettre à jour la caméra en cas de changement de taille de la fenêtre
  resize () {
    this.camera.aspect = this.game.viewport.sizes.width / this.game.viewport.sizes.height
    this.camera.updateProjectionMatrix()
  }
}

Viewport.ts

La classe Viewport va gérer la taille de la fenêtre. Elle va avoir la responsabilité de mettre à jour les dimensions et la résolution du rendu.

import Game from './Game.js'

export default class Viewport {
  domEl: typeof window
  sizes: { width: number, height: number }
  game: Game = Game.getInstance()

  constructor() {
    this.domEl = window // On récupère l'objet window, mais on pourrait passer un autre objet si on veut un rendu dans un autre élément
    this.sizes = {
      width: this.domEl.innerWidth,
      height: this.domEl.innerHeight
    }

    this.domEl.addEventListener('resize', () => {
      // On récupère la nouvelle taille de la fenêtre
      this.sizes.width = this.domEl.innerWidth
      this.sizes.height = this.domEl.innerHeight

      this.game.rendering.resize() // On met à jour la taille du rendu
      this.game.view.resize() // On met à jour le ratio de la caméra
    })
  }
}

Rendering.ts

La classe Rendering va gérer le rendu de la scène. Elle va s’occuper d’afficher le rendu de la scène pour chaque frame.

import { WebGLRenderer } from 'three'
import Game from './Game.js'

export default class Rendering {
  renderer: WebGLRenderer
  game: Game = Game.getInstance()

  constructor() {
    // On crée un renderer qui va afficher la scène dans le navigateur, on lui passe le canvas
    this.renderer = new WebGLRenderer({
      canvas: this.game.canvas as HTMLCanvasElement,
    })

    // On définit la taille du rendu
    this.resize()
  }

  resize (): void {
    // On met à jour la taille du rendu en fonction de la taille du viewport
    this.renderer.setSize(this.game.viewport.sizes.width, this.game.viewport.sizes.height)
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
  }

  render (): void {
    // On affiche la scène du point de vue de la caméra, cette fonction est à appeler à chaque frame, soit 60 fois par seconde.
    this.renderer.render(
        this.game.world.scene, // La scène à afficher
        this.game.view.camera // La caméra à utiliser
    )
  }
}

Time.ts

Enfin la classe Time va gérer le temps. Elle va permettre la boucle de rendu et de l’actualisation de la scène.

import { Clock } from 'three' // On importe la classe Clock de Three.js
import Game from './Game.js'

export default class Time {
  time: number = 0 // Temps écoulé depuis le début du jeu
  deltaTime: number = 0 // Temps écoulé depuis la dernière frame
  elapsedTime: number = 0 // Temps écoulé depuis le début de la frame
  clock: Clock = new Clock()
  game: Game = Game.getInstance()

  tick () {
    this.deltaTime = this.clock.getDelta() // On récupère le temps écoulé depuis la dernière frame
    this.elapsedTime = this.clock.getElapsedTime() // On récupère le temps écoulé depuis le début de la frame
    this.time += this.deltaTime // On incrémente le temps total

    window.requestAnimationFrame(() => this.tick()) // On demande au navigateur de rappeler la fonction tick à chaque frame
  }
}

Et donc …

Et donc on a tout ce qu’il faut pour démarrer notre jeu. On peut maintenant initialiser notre jeu dans notre fichier src/main.ts.

import Game from './core/Game'
import * as THREE from 'three'

// On crée une instance de Game en lui passant le canvas
const game = new Game(document.querySelector('canvas'))

// On positionne la caméra
game.view.camera.position.z = 5
game.view.camera.position.y = 1
game.view.camera.lookAt(0, 0, 0)

// On crée un plan
const planeGeometry = new THREE.PlaneGeometry(5, 5)
const planeMaterial = new THREE.MeshStandardMaterial({ color: 'greenyellow', side: THREE.DoubleSide })
const plane = new THREE.Mesh(planeGeometry, planeMaterial)
plane.position.y = -1
plane.rotation.x = Math.PI / 2
game.world.scene.add(plane)

// On crée un cube
const cubeGeometry = new THREE.BoxGeometry()
const cubeMaterial = new THREE.MeshStandardMaterial({ color: 'darkorange' })
const cube = new THREE.Mesh(cubeGeometry, cubeMaterial)
cube.position.set(0, 0, 2)
game.world.scene.add(cube)

// On crée une lumière ambiante
const ambientLight = new THREE.AmbientLight(0x404040)
ambientLight.intensity = 1
game.world.scene.add(ambientLight)

// On crée une lumière directionnelle
const light = new THREE.DirectionalLight(0xffffff, 2)
light.position.set(2, 2, 2)
game.world.scene.add(light)

game.start()

Enfin presque, car il manque quelque chose, on a effectivement tous les éléments, mais on ne les a pas encore fait fonctionner ensemble. En effet, on a une boucle de temps, mais on ne l’utilise pas pour mettre à jour la scène. Pour ça il nous manque un dernier élement, la possibilité de propager des événements permettant aux classes de mettre à jour leurs états.

Event.ts

On va créer une classe Event qui va permettre de propager et écouter des événements.

export default class Event {
  events: { [key: string]: Function[] } = {}

  // Méthode pour écouter un événement
  on (name: string, callback: Function): void {
    if (!this.events[name]) {
      this.events[name] = []
    }

    this.events[name].push(callback)
  }

  // Méthode pour arrêter d'écouter un événement
  off (name: string, callback: Function | null = null): void {
    if (!this.events[name]) {
      return
    }

    if (callback === null) {
      this.events[name] = []
      return
    }

    this.events[name] = this.events[name].filter(eventCallback => eventCallback !== callback)
  }

  // Méthode pour émettre un événement
  emit (name: string, data: any = null): void {
    if (!this.events[name]) {
      return
    }

    this.events[name].forEach(callback => {
      callback(data)
    })
  }
}

Il suffit ensuite de l’ajouter à notre classe Time et de l’utiliser pour propagation un événement à chaque frame.

import Event from './Event.js'

export default class Time {
  // ...
  events: Event = new Event()

  tick () {
    // ...

    this.events.emit('tick', this)

    window.requestAnimationFrame(() => this.tick())
  }
}

Et enfin, on écoute cet événement dans la classe Rendering.

export default class Rendering {
  // ...
  events: Event = new Event()

  constructor() {
    // ...

    this.game.time.events.on('tick', () => this.render()) // On écoute l'événement tick pour rendre la scène
  }
}

On peut même ajouter un écouteur d’événement dans src/main.ts pour mettre du mouvement au cube.

game.time.events.on('tick', () => {
  cube.rotation.x += 0.01
  cube.rotation.y += 0.01
})

And voilà !

Bon ok, c’est sensiblement la même chose que dans le premier article, mais on a une bien meilleure base de code pour la suite. D’ailleurs dans la suite, on verra comment gérer les élèments de notre jeu. On verra comment fonctionne les moteurs de jeu pour s’inspirer des bonnes pratiques.

Bonus

On va rajouter des outils de debug pour notre jeu. On va utiliser tweakpane pour afficher des informations que l’on souhaite débugger (par exemple l’intensité de la luminosité ambiance). Ainsi que stats.js pour afficher des informations sur les performances.

On installe d’abord tweakpane.

npm install -D tweakpane

Ensuite, on va créer deux classes, src/core/debug/Debug.ts pour afficher les informations avec tweakpane et src/core/debug/FPSDebug.ts pour afficher les performances avec stats.js.

import {Pane} from 'tweakpane'

// On verra dans les prochaines articles comment utiliser tweakpane
export default class Debug {
  pane: Pane = new Pane()
}
import Stats from 'three/addons/libs/stats.module.js' // Stats est directement disponible dans Three.js
import Game from "../Game.js";

export default class FPSDebug {
  stats: Stats = new Stats()
  game: Game = Game.getInstance()

  constructor() {
    this.stats.showPanel(0) // 0: fps

    document.body.appendChild(this.stats.dom)

    this.game.rendering.events.on('render', () => this.stats.begin())
    this.game.rendering.events.on('renderend', () => this.stats.end())
  }
}

Pour FPSDebug.ts, stats.js a besoin de savoir quand commence et finit le rendu. On va donc ajouter des événements dans Rendering.ts.

export default class Rendering {
  // ...
  events: Event = new Event()

  render() {
      this.events.emit('render')
      // ...
      this.events.emit('renderend')
  }
}

Enfin, on ajoute ces outils dans la classe Game et on activera le debug lorsqu’on ajoute ?debug dans l’url.

import FPSDebug from './debug/FPSDebug.js'
import Debug from './debug/Debug.js'

export default class Game {
  // ...
  debug: Debug | null = null
  fpsDebug: FPSDebug | null = null

  constructor(canvas: HTMLCanvasElement | null) {
    // ...
    if (window.location.search.includes('debug')) {
      this.debug = new Debug()
      this.fpsDebug = new FPSDebug()
    }
  }
}

Cela va nous être utile pour la suite, car on va commencer à ajouter pleins d’éléments dans notre jeu et c’est plus facile de pouvoir jouer avec les paramètres en direct pour voir le comportement de certains paramètres ThreeJs.

On se retrouve donc dans la prochaine partie. Enjoy !