Un micro FPS en JavaScript avec Babylonjs

Dans le dernier article nous avons vue les bases pour réaliser une scène en 3D avec WebGL et Babylon.js. Maintenant nous allons tenter d’être légèrement plus ambitieux en créant quelque chose qui pourrait s’apparenter à un mini FPS. Ne vous affolez pas, ça sera très moche, mais nous aurons une arme qui pourra tirer des munitions. Chaque munition sera capable de détruire un ennemie et ça sera tout pour cette partie. C’est déjà pas mal et vous savez quoi ? Nous n’avons que deux blocs de code à voir par rapport au dernier tutoriel. Pour faire les choses bien nous allons dans un premier temps modifier la scène de démo en utilisant un sol plus grand et en ajoutant plus de caisses. Ensuite nous créerons l’arme du joueur et enfin nous coderons le comportement des munitions au clic de souris. Vous êtes prêt ? Bon et bien c’est partie !

Une arme, des munitions : Un FPS ?
Une arme, des munitions : Un FPS ?

Modification du fichier index.html

Si vous ne l’avez pas déjà fait, je vous invite à récupérer les sources du premier article, elles sont disponibles sur github à cette adresse.

<!doctype html>
<html lang="fr">
<head>
	<meta charset="utf-8" />
	<title>Demonixis.Net :: Babylon.js - Partie 2</title>
    <style>
		  body, html {
			    margin: 0; 
			    padding: 0; 
			    overflow: hidden;
		    	width: 100%;
			    height: 100%;
		}
		canvas {
			    width: 100%;
			    height: 100%;
		}	

		#crosshair {
			    position: absolute;
			    left: 50%;
			    top: 50%;
			    width: 5px;
			    height: 5px;
			    z-index: 999;
			    background-color: white;
		}
	</style>
</head>
<body>
	   <span id="crosshair"></span>
	   <canvas id="renderCanvas"></canvas>
<script src="js/babylon.1.6.js"></script>
<script src="js/hand-1.1.3.js"></script>
<script src="js/demo.js"></script>
<script>
   	runDemo("renderCanvas");
</script>
</body>
</html>

La grosse nouveauté est la balise span qui est placée au milieu de l’écran, elle nous servira de viseur. J’attire d’ailleurs votre attention sur le fait que l’interface graphique d’un jeu web doit être réalisée en HTML/CSS car faire de l’UI dans un Canvas est plus lent et ne servira strictement à rien. Vous avez à votre disposition un super langage pour faire des interfaces alors ne vous en privez pas. N’oubliez pas par contre de spécifier un z-index plus élevé que celui du canvas.

Modification de la scène

function createDemoScene(scene) {
    // Création d'un sol
	   var ground = BABYLON.Mesh.CreatePlane("ground", 150, scene);
	   ground.rotation.x = Math.PI / 2;
	   ground.material = new BABYLON.StandardMaterial("gMaterial", scene);
	   ground.material.diffuseTexture = new BABYLON.Texture("images/ground.png", scene);
	   ground.checkCollisions = true;

	   // Et quelques cubes...
	   var boxMaterial = new BABYLON.StandardMaterial("bMaterial", scene);
	   boxMaterial.diffuseTexture = new BABYLON.Texture("images/box.png", scene);

	   var cubeSize = 2.5;

	   for (var i = 0; i < 15; i++) {
		      var box = BABYLON.Mesh.CreateBox("box1", cubeSize, scene);
		      box.tag = "enemy";
		      box.position = new BABYLON.Vector3(random(0, 50), cubeSize / 2, random(0, 50));
		      box.material = boxMaterial;
		      box.checkCollisions = true;
	  }
}

function random(min, max) {
	  return (Math.random() * (max - min) + min);
}

Le tableau de position des caisses a été supprimé et remplacé par un positionnement aléatoire, vous noterez d’ailleurs la présence de la fonction random(min, max) qui permet de récupérer un nombre pseudo aléatoire (bien plus efficace qu’un Math.random()) compris entre min et max. Sur chaque caisse nous ajoutons une variable tag ayant la valeur enemy, cela nous permettra plus tard de reconnaître ce type d’objet comme étant un ennemie N’ayez pas peur d’ajouter des propriétés à chaud sur un objet JavaScript, par contre faites le bien. Effectivement l’idéal est à mon avis de le faire à la création de l’objet pour que ce soit disponible tout de suite, si c’est fait plus tard dans le code cela peut donner des comportements bancales (c’est ce genre de mauvaise pratique qui fait dire au gens que ce langage est merdique). Donc comme toutes les bonnes choses, n’en abusez pas mais utilisez les.

Ajout d’une arme (enfin ça y ressemble)

La première chose avant de commencer est d’ajouter la ligne suivante dans le fonction runDemo()

document.addEventListener("contextmenu", function (e) { e.preventDefault();	});

Ça évite d’avoir un menu contextuel qui s’ouvre lorsque l’on fait un clic gauche avec la souris. Bon plus sérieusement, passons à l’ajout de l’arme. Comme nous ne savons pas encore charger des modèles 3D (mais ça viendra après), on va simplement simuler une arme avec un cube que l’on va étirer sur l’axe Z. Ensuite on placera le cube en tant que parent à la caméra et pas à la scène… Pourquoi donc ? Et bien comme ça les transformations de la caméra (position et rotation) seront automatiquement répercutés sur le cube ! Donc si vous levez la caméra pour regarder en haut alors le cube suivra sans qu’on lui demande quoi que ce soit, idem pour la position. Je vous invite maintenant à mettre ce code dans la fonction runDemoScene. juste après l’appel à la fonction createDemoScene.

var weapon = BABYLON.Mesh.CreateBox("weapon", 1, scene);
weapon.scaling = new BABYLON.Vector3(0.2, 0.2, 0.5);
weapon.material = new BABYLON.StandardMaterial("wMaterial", scene);
weapon.material.diffuseTexture = new BABYLON.Texture("images/weapon.png", scene);
weapon.position.x = 0.4;
weapon.position.y = -0.3;;
weapon.position.z = 1;;
weapon.parent = camera;

Comme d’habitude les noms du mesh et de la material sont totalement arbitraire. Le positionnement a été fait de manière un peu empirique je vous l’avoue 😛 Mais concrètement on déplace le cube de 0.4 sur l’axe X afin que l’arme soit à droite, on passe sa position en y à -0.3 afin qu’elle ne soit pas à la même auteur que la caméra (elle apparaît presque centrée sur l’axe Y). Enfin on l’avance en avant avec une position à Z de 1. Sachez que dés que si vous modifiez la valeur d’échelle de l’arme il faudra revoir ces positions. Si vous lancez le jeu maintenant vous aurez une arme qui suis la caméra ! Facile non ? Maintenant on va tirer !

La classe Bullet

Afin d’éviter de mettre du code n’importe où (genre dans une fonction callback), nous allons créer une classe (enfin ce qu’on assimile à une classe en JavaScript) Bullet qui sera chargée de créer un mesh en forme de sphere et de mettre à jour sa position ainsi que de gérer sa durée de vie (wahoo tout ça ?).

var Bullet = function (camera, scene) {
	// 1. Création du mesh et du material de la munition
	var mesh = BABYLON.Mesh.CreateSphere("bullet", 1, 1, scene);
	mesh.scaling = new BABYLON.Vector3(0.1, 0.1, 0.1);
	mesh.material = new BABYLON.StandardMaterial("bMat", scene);
	mesh.material.diffuseColor = new BABYLON.Color3(1, 0, 0);
	mesh.position = camera.position.clone();

	// 2. On determine la direction
	var direction = getForwardVector(camera.rotation);
	direction.normalize();

	// 3. Il est vivant ! (pour le moment)
	var alive = true;
	var lifeTimer = null;

	var internalDispose = function () {
		 if (alive) {
			  if (lifeTimer) {
				    clearTimeout(lifeTimer);
			  }

			mesh.dispose();
			lifeTimer = null;
			alive = false;
		}  
	};

	// 4. Au bout de 1.5 secondes on supprime le projectil de la scène.
	lifeTimer = setTimeout(function() {
		internalDispose();
	}, 1500);

	// La vitesse est publique, on peut la modifier facilement
	this.speed = 0.5;

	// 5. Logique de mise à jour
	this.update = function () {

	};

	this.dispose = function () {
		internalDispose();
	};
};

J’ai supprimé le contenu de la méthode update pour le moment car je vais vous expliquer ma démarche concernant cette classe. Le constructeur prend en paramètre la caméra active et la scène, cela nous permet de créer l’instance d’une munition et de récupérer les informations de la caméra comme sa position et sa rotation.

On créé donc le mesh d’une munition qui sera au final une simple sphère avec une material rouge. Sa position de départ sera celle de la caméra. Maintenant il faut déterminer la direction dans laquelle la balle doit aller, cela se fait avec une fonction personnalisée que j’ai nommé getForwardVector, je la détaillerais plus bas. Une balle est caractérisée par sa vitesse et sa durée de vie, il faut donc ajouter une variable booléenne qui nous indique si la balle est en vie (par défaut elle vaut true) et il faut aussi lancer un timer qui va « tuer » la balle et ne plus la mettre à jour ni l’afficher, ici il est fixé à 1500 millisecondes. Lorsque la méthode internalDispose() est appelée (soit par le timer, soit par le code qui créé la munition) on vérifie si l’objet est actif, si oui alors on vérifie si le timer est encore actif et on le supprime. La suppression d’un mesh sur la scène se fait avec la méthode dispose de l’objet Mesh. La méthode internalDispose() supprime donc le mesh de la scène et empêche la mise à jour et l’affichage de ce dernier. Passons à la méthode update.

this.update = function () {
	 if (!alive) {
		  return false;
	 }

	 // On incrémente la position avec la direction et la vitesse désirée.
	 mesh.position.x += direction.x * this.speed;
	 mesh.position.y += direction.y * this.speed;
	 mesh.position.z += direction.z * this.speed;

	 // On test les collision manuellement. Si on tombe sur un objet ayant un tag
	 // Alors on le supprime
	 var meshToRemove = null;
	 var i = 0;
	 var size = scene.meshes.length;
	 var hit = false;

	 while (i < size && !hit) {
		  if (scene.meshes[i].tag && mesh.intersectsMesh(scene.meshes[i], false)) { 
			   meshToRemove = scene.meshes[i];
		  }
		  i++;
	 }

	 if (meshToRemove) {
		  meshToRemove.dispose();
		  return true;
	 }

	 return false;
};

La position de la balle est mise à jour ici, on a calculé sa direction à la construction et on incrémente sa position en fonction de la direction et de la vitesse. Souvenez vous à la construction nous devions passer la scène et nous allons justement l’exploiter dans cette fonction.

Cette méthode doit retourner true si la balle est en collision avec un objet de type enemy, sinon elle retourne false. Si vous reprenez le code qui créé les caisses du haut (qui je le rappel sont associées à un ennemie), vous verrez qu’on a spécifié un champ tag, on l’exploite dans cette fonction afin de déterminer si on rentre ou pas en collision avec une caisse. La détection de collision est réalisé avec la méthode intersectsMesh, elle prend en paramètre un autre mesh et un booléen qui indique la précision de détection. Comme toutes nos caisses sont immobiles et statiques on laisse le paramètre à false. Si la balle entre en collision avec une caisse alors on stock l’indice de l’objet à détruire, on sort de la boucle et on supprime l’objet de la scène avec sa méthode dispose.

function getForwardVector(rotation) {
	 var rotationMatrix = 
    BABYLON.Matrix.RotationYawPitchRoll(rotation.y, rotation.x, rotation.z);   
  var forward = 
    BABYLON.Vector3.TransformCoordinates(new BABYLON.Vector3(0, 0, 1), rotationMatrix);
	 return forward;
}

(note à part: les blocs de code de mon blog ne sont clairement pas adaptés aux longues fonctions désolé :'( ). Il fallait bien qu’on fasse un peu de math et c’est la fonction getForwardVector() qui s’en charge. Comme vous l’avez vue il faut que l’on détermine la direction dans laquelle la balle va aller, cette direction est celle de la caméra. La première étape est de créer la matrice rotation de la caméra, pour ce faire on utilise la méthode RotationYawPitchRoll qui rappellera sans doutes de bons souvenirs aux développeurs XNA. Une fois qu’on a cette matrice, la méthode TransformCoordinates va nous retourner un Vector3 qui sera la direction de la caméra. Cette méthode prend en paramètre un vecteur direction de référence, on passe (0, 0, 1) qui correspond à un déplacement positif sur l’axe Z (en avant) et la matrice rotation de la caméra.

La boucle principale

Mine de rien nous avons presque terminé, il faut encore faire deux choses. Primo nous devons créer une collection de balle et enfin la mettre à jour à chaque itération de la boucle de rendu.

var bullets = [];
canvas.addEventListener("mouseup", function (e) {
	 var bullet = new Bullet(camera, scene);
	 bullets.push(bullet);
});

// Lancement de la boucle principale
engine.runRenderLoop(function() {
	 var toRemove = [];
	 for (var i = 0, l = bullets.length; i < l; i++) {
		   if (bullets[i].update()) {
			    toRemove.push(i);
			    bullets[i].dispose();
		   }
	 }

	 for (var i = 0, l = toRemove.length; i < l; i++) {
		  bullets.splice(toRemove[i], 1);
	 }

	 scene.render();
});

A l’événement mouseup (c’est à dire quand on relâche le bouton de la souris), on créé une balle et on l’ajoute à la collection des balles, jusqu’à là c’est très facile. Dans la fonction de la boucle principale on va mettre à jour toutes les balles. Si une balle rentre en collision avec un caisse (si la méthode update renvoie true) alors on la supprime de la scène avec la méthode dispose (de l’objet Bullet qui supprimera aussi la caisse). Dans la deuxième boucle on supprime les balles qui ne doivent plus être mises à jour, la méthode splice est utilisée pour cette opération.

Conclusion

Babylon.js est un moteur comportant les fonctionnalité nécessaires à la réalisation d’un petit jeu 3D, vous l’aurez compris, le plus gros du travail va être du côté des assets et des comportements (IA, SoundDesign, LevelDesign, GamePlay) que du code bas niveau à proprement parler. Avec un bon éditeur de scène vous pourrez réaliser des merveilles ! Dans les prochains tutoriels nous verrons comment importer des modèle 3D réalisés avec des éditeurs tels que Blender et même Unity3D (avec des plugins supplémentaires évidement). Vous pouvez retrouver le contenu de ce tutoriel sur github à cette adresse. Vous pouvez aussi essayer la démo en ligne.