Introduction

Nous avons vu précédemment une introduction à notre jeu de course de voitures de type Outrun, mais comment débuter pour créer un jeu de course en pseudo 3D ?

Image non disponible
Démo de cette partie

Voici ce dont nous aurons besoin :

  • réviser un peu de trigonométrie ;
  • réviser quelques bases de la projection 3D ;
  • créer une boucle de jeu ;
  • charger des images de sprites ;
  • créer la géométrie pour la route ;
  • afficher un fond ;
  • afficher la route ;
  • afficher la voiture ;
  • mettre en place la gestion du clavier pour la conduite.

Avant de commencer tout cela, je vous invite à regarder Lou's Pseudo 3d Page. C'est la principale source d'information (enfin... que j'ai pu trouver) en ligne concernant la création d'un jeu de course en pseudo 3D.

Le rendu de la page de Lou n'est pas concluant sur Chrome, je vous conseille d'utiliser Firefox ou IE.

Vous avez fini la lecture de la page de Lou ? Parfait ! Nous allons nous baser sur son approche de « Montagnes réalistes avec des segments projetés en 3D ». Nous le ferons pas à pas au cours de cette série de quatre articles en démarrant avec la v1 : créer de très simples portions de route droites et les projeter dans notre élément HTML5 canvas.

Voir l'exemple de cette étape.

Un peu de trigonométrie

Avant de commencer à coder, révisons un peu de trigonométrie et souvenons-nous comment projeter un point d'un espace en 3D sur un écran en 2D.

Pour rester simple et sans entrer dans des considérations de vecteurs et de matrices, la projection 3D utilise la loi des triangles similaires. Si nous appelons :

  • h la hauteur de la caméra :
  • d la distance de la caméra à l'écran ;
  • z la distance de la camera à la voiture ;
  • y la coordonnée verticale de l'écran.

Alors nous pouvons utiliser la loi des triangles similaires pour calculer

 
Sélectionnez

y = h*d/z

Comme le montre le diagramme suivant :

Image non disponible

Nous aurions pu dessiner un diagramme similaire mais à partir d'une vue en plongeante hauteur au lieu de la vue sur le côté représentée ci-dessus. Sur cette autre diagramme, l'équation pour calculer la coordonnée x aurait été :

 
Sélectionnez

x = w*d/z

w représente la moitié de la largeur de la piste.

Vous pouvez constater que pour les coordonnées x et y, nous créons juste un rapport de d/z.

Système de coordonnées

Cela semble très simple sous forme de diagramme, mais lorsque l'on commence à coder, cela peut facilement se compliquer et s'avérer déroutant, notamment parce que les variables ont été nommées de façon un peu vague et ne permettent pas de différencier celles représentant le monde 3D et celles correspondant à la représentation 2D. D'autre part, nous avons supposé que la caméra était positionnée à l'origine de notre monde, alors qu'en réalité, elle suit la voiture.

Concrètement, nous devrons :

  1. Translater les coordonnées réelles vers les coordonnées de la caméra.
  2. Projeter les coordonnées de la caméra sur un plan normalisé.
  3. Ajuster la projection aux dimmensions de l'écran (dans notre cas, le canvas).
Image non disponible

Dans un véritable système 3D, une phase de rotation serait nécessaire entre les étapes 1 et 2, mais comme nous n'allons que simuler les courbes, nous n'en aurons pas besoin ici.

Projection

Nous pouvons maintenant écrire nos équations de projection comme ceci :

Image non disponible
  • L'quation translate calcule les coordonnées de la caméra.
  • L'équation project est une évolution de notre loi des triangles similaires ci-dessus.
  • L'équation scale prend en compte les différences entre :
  • les maths, où (0,0) correspond au centre et l'axe y monte ; les écrans, où (0,0) est en haut à gauche et l'axe y descend.
Image non disponible

Dans un vrai projet 3D, nous aurions défini des classes Vector et Matrix pour se charger des mathématiques 3D de façon robuste. Tant qu'à faire, nous aurions même utilisé WebGL (ou un équivalent). Mais là n'est pas notre but dans cet article qui cherche à créer un jeu de type Outrun "à l'ancienne" en pseudo 3D.

Encore un peu de trigonométrie

La dernière pièce du puzzle est de calculer d, la distance de la caméra au plan de projection.

Plutôt que de fixer une valeur pour d en dur, mieux vaut la calculer à partir de l'élévation voulue de la caméra. Ainsi, nous pourrons prévoir une fonction de zoom si nécessaire.

Image non disponible

En supposant que la projection est plane avec des coordonnées variant entre -1 et 1, nous pouvons calculer d comme ceci :

 
Sélectionnez

d = 1/tan(fov/2)

En définissant fov comme une variable, nous pourrons par la suite affiner l'algorithme de rendu à partir de celle-ci.

Structure du code JavaScript

J'ai déjà précisé que ce code ne respecte pas forcément les meilleures pratiques JavaScript. Il s'agit juste d'une démo rapide (et pas toujours propre) utilisant juste des variables globales et quelques fonctions. Cependant, comme je vais créer quatre versions distinctes (lignes droites, courbes, collines et sprites), je vais utiliser des méthodes réutilisables dans common.js au travers des modules suivants :

  • Dom : quelques utilitaires concernant le DOM ;
  • Util : des utilitaires génériques, principalement des méthodes mathématiques ;
  • Game : des méthodes génériques concernant le jeu lui-même, comme des chargements d'images ;
  • Render : méthodes utiles pour le rendu du canvas.

Je ne détaillerai les méthodes de common.js que si elles concernent spécifiquement ce jeu et non de simples utilitaires DOM ou mathématiques. Cependant, le nom et le contexte vous permettent de savoir ce que chaque méthode est supposée faire.

Comme toujours, le code source constitue la meilleure documentation.

Une boucle de jeu simple

Avant de pouvoir afficher quoi que ce soit, nous avons besoin d'une boucle de jeu. Si vous avez suivi mes précédents articles sur les jeux (pong, breakout, tetris, snakes ou boulderdash) alors vous avez déjà rencontré mon algorithme préféré de fréquence de rafraichissement.

Je ne reviendrais pas ici sur les détails, je vais me contenter de réutiliser du code venant de mes précédents jeux pour gérer le rafraichissement périodique de la boucle de jeu à l'aide de requestAnimationFrame.

L'idée étant que chacun de mes exemples puisse appeler Game.run(...) qui déterminera la version à utiliser de

  • update : l'univers du jeu avec sa fréquence de rafraichissement ;
  • render : l'univers du jeu si le navigateur est compatible.
 
Sélectionnez

run: function(options) {

	Game.loadImages(options.images, function(images) {
	
		var update = options.update,    // la méthode pour définir la logique du jeu est déterminée à l'appel du script
			render = options.render,    // la méthode pour mettre à jour l'affichage est déterminée à l'appel du script
			step   = options.step,      // la fréquence de rafraichissement (1/fps) est déterminée à l'appel du script
			now    = null,
			last   = Util.timestamp(),
			dt     = 0,
			gdt    = 0;
		
		function frame() {
			now = Util.timestamp();
			dt  = Math.min(1, (now - last) / 1000); // l'utilisation de requestAnimationFrame doit prévoir de longues périodes d'inactivité dues au passage en mode réduit ou à l'activation d'un autre onglet
			gdt = gdt + dt;
			while (gdt > step) {
				gdt = gdt - step;
				update(step);
			}
			render();
			last = now;
			requestAnimationFrame(frame);
		}
		frame(); // commençons le jeu
	});
}

Encore une fois, il s'agit de concepts venant de mes jeux précédents, donc si vous avez besoin d'explications, lisez les articles correspondants (NdT : sur Code inComplete).

Images et sprites

Avant de démarrer le jeu, nous devons charger deux planches de sprite :

  • les fonds : trois couches synchronisées pour le ciel, les collines et les arbres ;
  • les vignettes : essentiellement celles concernant la voiture, mais aussi les arbres et panneaux d'affichage pour la version finale.

La feuille de sprite a été générée avec une routine Ruby sprite-factory Ruby Gem. Cette routine génère à la fois la feuille contenant les sprite et les coordonnées x, y w, h pour les utiliser.

Image non disponible

Les images de fond sont faites maison à l'aide de Inkscape et les sprites sont des images empruntées de la version d'origine de Outrun et utilisées ici à titre d'exemple. Si certains graphistes sont disponibles pour proposer des images originales en vue d'un nouveau jeu, qu'ils prennent contact avec moi !

Les variables du jeu

Pour compléter les images que nous utiliserons, nous aurons aussi besoin d'un certain nombre de variables que voici :

 
Sélectionnez

var fps           = 60;                      // fréquence de rafraichissement
var step          = 1/fps;                   // durée de chaque "écran" (en seconde)
var width         = 1024;                    // largeur réelle du canvas
var height        = 768;                     // hauteur réelle du canvas
var segments      = [];                      // tableau des portions de route
var canvas        = Dom.get('canvas');       // la balise canvas...
var ctx           = canvas.getContext('2d'); // ... et son contexte 2D
var background    = null;                    // l'image de fond (chargée précédemment)
var sprites       = null;                    // la feuille de vignettes (chargée précédemment)
var resolution    = null;                    // le facteur de rapport pour gérer la résolution (calculé)
var roadWidth     = 2000;                    // largeur actuelle de la route, facilite les calculs si l'envergure varie de -roadWidth à +roadWidth
var segmentLength = 200;                     // longueur d'un segment
var rumbleLength  = 3;                       // nombre de segments par bande rouge ou blanche
var trackLength   = null;                    // profondeur de la piste (calculé)
var lanes         = 3;                       // nombre de lignes
var fieldOfView   = 100;                     // angle (degrés) de vision
var cameraHeight  = 1000;                    // hauteur z de la caméra
var cameraDepth   = null;                    // distance z de la caméra à l'écran (calculé)
var drawDistance  = 300;                     // nombre de portions à dessiner
var playerX       = 0;                       // distance x du joueur au centre de la route (-1 to 1 pour rester cohérent avec roadWidth)
var playerZ       = null;                    // distance z relative du joueur par rapport à la caméra (calculé)
var fogDensity    = 5;                       // densité exponentielle du brouillard
var position      = 0;                       // position z actuelle de la caméra (ajouter playerZ pour obtenir la position absolue Z du joueur)
var speed         = 0;                       // vitesse actuelle
var maxSpeed      = segmentLength/step;      // vitesse maximale (facilite la détection de collision en s'assurant que l'on ne peut avancer de plus d'un segment par rafraichissement)
var accel         =  maxSpeed/5;             // accélération - calculé manuellement jusqu'à une valeur naturelle
var breaking      = -maxSpeed;               // taux de décélération en freinant
var decel         = -maxSpeed/5;             // décélération naturelle si l'on accélère ou ne freine pas
var offRoadDecel  = -maxSpeed/2;             // décélération hors chaussée (entre les deux précédentes)
var offRoadLimit  =  maxSpeed/4;             // limite pour laquelle il n'y a plus de décélération en dehors de la route (c'est-à-dire vitesse maximale hors route)

Certaines valeurs peuvent être affinées en utilisant les contrôles de jeu des exemples afin de de voir leur effet en situation. Les autres dérivent des valeurs ajustables et sont calculées dans la méthode reset().

Conduire une Ferrari

Nous ajoutons une gestion du clavier à Game.run permettant d'ajuster des variables pour refléter les actions de l'utilisateur :

 
Sélectionnez

Game.run({
	...
	keys: [
		{ keys: [KEY.LEFT,  KEY.A], mode: 'down', action: function() { keyLeft   = true;  } },
		{ keys: [KEY.RIGHT, KEY.D], mode: 'down', action: function() { keyRight  = true;  } },
		{ keys: [KEY.UP,    KEY.W], mode: 'down', action: function() { keyFaster = true;  } },
		{ keys: [KEY.DOWN,  KEY.S], mode: 'down', action: function() { keySlower = true;  } },
		{ keys: [KEY.LEFT,  KEY.A], mode: 'up',   action: function() { keyLeft   = false; } },
		{ keys: [KEY.RIGHT, KEY.D], mode: 'up',   action: function() { keyRight  = false; } },
		{ keys: [KEY.UP,    KEY.W], mode: 'up',   action: function() { keyFaster = false; } },
		{ keys: [KEY.DOWN,  KEY.S], mode: 'up',   action: function() { keySlower = false; } }
	],
	...
}

Les variables impactées sont :

  • speed : la vitesse actuelle ;
  • position : la position z actuelle par rapport au sol. Notez qu'il s'agit de la position de la caméra, pas de la Ferrari ;
  • playerX : la position x actuelle sur la route. Elle va de -1 à +1 pour rester en cohérence avec roadWidth.

Ces variables sont mises à jour par la méthode update qui va :

  • ajuster position en fonction de la valeur de speed ;
  • ajuster playerX si les touches gauche ou droites sont enfoncées ;
  • augmenter speed si la touche haut est enfoncée ;
  • diminuer speed si la touche bas est enfoncée ;
  • diminuer speed si ni haut ni bas ne sont enfoncées ;
  • ajuster speed si playerX est en dehors de la route.

Pour une route droite, la méthode update est assez simple :

 
Sélectionnez

function update(dt) {

	position = Util.increase(position, dt * speed, trackLength);
	
	var dx = dt * 2 * (speed/maxSpeed); // à la vitesse maximale, il faut pouvoir traverser la route (de -1 à 1) en 1 seconde
	
	if (keyLeft)
		playerX = playerX - dx;
	else if (keyRight)
		playerX = playerX + dx;
	
	if (keyFaster)
		speed = Util.accelerate(speed, accel, dt);
	else if (keySlower)
		speed = Util.accelerate(speed, breaking, dt);
	else
		speed = Util.accelerate(speed, decel, dt);
	
	if (((playerX < -1) || (playerX > 1)) && (speed > offRoadLimit))
		speed = Util.accelerate(speed, offRoadDecel, dt);
	
	playerX = Util.limit(playerX, -2, 2);     // ne pas laisser le joueur aller trop loin en dehors de la route
	speed   = Util.limit(speed, 0, maxSpeed); // ni dépasser la vitesse maximale

}

Ne vous inquiétez pas, cela deviendra beaucoup plus compliqué lorsque nous ajouterons les sprites et la détection de collision.

Géométrie de la route

Avant de pouvoir afficher l'univers du jeu, nous devons créer le tableau des segments de la route avec la méthode resetRoad().

Tous ces éléments de route devront être projetés en coordonnées 2D sur l'écran. Nous devons donc mémoriser deux points par segment, p1 est le centre du bord le plus proche de la caméra, p2 est le centre du bord le plus lointain.

Image non disponible

Techniquement, chaque élément p2 est identique à l'élément p1 du segment précédent, mais il est plus simple de les différencier et de transformer chaque portion isolément.

Nous regroupons plusieurs segments par rumbleLength pour pouvoir obtenir des courbes et du relief détaillés avec de larges bandes. Si chaque segment alternait de couleur, nous obtiendrions un effet stroboscopique désagréable. Nous voulons donc plus de segments mais associés pour former des bandes plus longues.

 
Sélectionnez

function resetRoad() {
	segments = [];
	for(var n = 0 ; n < 500 ; n++) { // arbitrary road length
		segments.push({
			index: n,
			p1: { world: { z:  n   *segmentLength }, camera: {}, screen: {} },
			p2: { world: { z: (n+1)*segmentLength }, camera: {}, screen: {} },
			color: Math.floor(n/rumbleLength)%2 ? COLORS.DARK : COLORS.LIGHT
		});
	}
	
	trackLength = segments.length * segmentLength;
}

Nous initialisons p1 et p2 uniquement avec la coordonnée z puisque nous ne gérons que des lignes droites. La coordonnée y vaudra toujours 0 et la coordonnée x sera toujours basée sur une pondération de roadWidth. Cela changera lorsque nous introduirons les courbes et le relief.

Nous initialisons aussi des objets vides pour stocker les représentations de ces points par rapport à la caméra et à l'écran. Il est préférable d'éviter de créer trop d'objets temporaires à chaque appel de render pour préserver le ramasse-miettes. En règle générable, il faut éviter de créer des objets dans la boucle de jeu.

Lorsque la voiture atteint le bout de la route, il suffit de revenir au début de la boucle. Pour faciliter un peu les choses, nous créons une méthode pour récupérer un segment pour toute valeur de z, même si elle dépasse la longueur de la route :

 
Sélectionnez

function findSegment(z) {
	return segments[Math.floor(z/segmentLength) % segments.length];
}