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.
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.
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).
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()
}
}
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()
}
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()
}
}
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
})
}
}
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
)
}
}
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 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.
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.
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 !