FMX Corridor en 3D avec Delphi et FireMonkey

Grégory Bersegeay

Ce tutoriel va montrer la réalisation d'un nouveau petit jeu en 3D avec Delphi en utilisant le framework FireMonkey (FMX). Après mon premier tutoriel qui reprenait le jeu Pong, cette fois je me suis inspiré d'un autre jeu moins connu : The Light Corridor (c'était sur Atari ST et Amiga 500, toute une époque !).

Commentez

- Titre : FMX Corridor en 3D avec Delphi et FireMonkey

- Auteur : Grégory Bersegeay

- Parution : 16 janvier 2018

- Public visé :

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2018 Grégory Bersegeay. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Prérequis

- II. Présentation du projet

- III. Le jeu

- III-A. Construction d'un niveau

- III-B. Interface graphique

- III-C. Principes de fonctionnement général

- III-D. Initialisation du jeu

- III-E. Interaction de l'utilisateur avec la raquette

- III-F. Gestion des collisions de la balle avec les obstacles

- III-G. Gestion du déplacement de la balle

- III-H. Animation

- IV. Les petits plus

- V. Améliorations futures

- VI. Remerciements

I - Prérequis

Ce tutoriel a été réalisé sous Delphi Tokyo (10.2.2) édition professionnelle avec le plugin mobile. J'ai fait des tests concluants sous Windows et Mac OS. Pour la partie mobile, j'ai fait des essais pour Android, mais pas IOS, car je ne dispose pas de périphérique adéquat. Pour Android et depuis le passage à Tokyo, j'ai des problèmes dès que j'utilise de la 3D. Le projet source joint à ce tutoriel rencontre des problèmes d'affichage sous Android lorsqu'il est compilé avec Tokyo, alors que tout se passe bien lorsqu'il est compilé avec Berlin…

Ce tutoriel n'utilise aucun composant tiers. Le projet est compatible avec des versions antérieures de Delphi (Seattle et Berlin) de même qu'avec les éditions Starter (qui sont gratuites pour rappel:) ).

D'un point de vue développement, si vous connaissez déjà Delphi, c'est un plus, car je ne détaillerai pas comment ouvrir un projet, compiler, naviguer dans la palette des composants et l'inspecteur d'objets… Si vous ne connaissez pas Delphi, il n'y a rien de compliqué, mais ces actions ne seront pas détaillées dans ce tutoriel.

Enfin, comme ce fut le cas pour le tutoriel Pong (https://gbegreg.developpez.com/tutoriels/delphi/firemonkey/creation-jeu-3d/), ce projet utilise une source de lumière. Du coup, votre PC (et surtout la carte graphique) doit prendre en charge DirectX 11 et le shader model 5. Si ce n'est pas le cas, tel quel, le rendu sera tout rouge… Pour y remédier, il faut ajouter la ligne suivante dans le source du projet avant l'instruction Application.Initialize; :

fmx.types.GlobalUseDXSoftware := True;

Ainsi, après avoir recompilé le projet, vous devriez avoir les bonnes couleurs et le bon rendu. En fait, cette instruction permet de désactiver l'accélération graphique de votre carte graphique et de forcer un rendu logiciel. L'inconvénient est que vous ne profiterez pas de l'accélération graphique de votre GPU, mais pour ce petit projet ça ne devrait pas se sentir.

II - Présentation du projet

Pour mon premier tutoriel pour Développez, j'avais choisi le jeu Pong. Cette fois-ci, si le jeu qui m'a inspiré est nettement moins connu, le principe de jeu reste très simple et, au niveau réalisation, cela me paraissait faisable.

Cette fois, point d'IA, mais une « véritable » 3D, car la balle va se déplacer sur les trois axes (X, Y et Z). Dans le jeu Pong, le palet ne se déplaçait que sur deux axes. Autre nouveauté dans ce projet, le point de vue de l'utilisateur se déplace alors que pour Pong la caméra était fixe.

Quelques petites différences donc, mais j'ai repris les principes utilisés dans le Pong, à savoir : l'utilisation d'une animation pour la boucle principale du jeu, ainsi que le principe de scène à afficher en fonction de l'état du jeu et du code commenté. J'ai également essayé de rendre le code simple et accessible.

Delphi permet également de faire pas mal de choses de manière interactive via le concepteur graphique ; !

Le projet de ce tutoriel

Le jeu d'origine The Light Corridor

Le code source du projet est disponible ici : https://gbegreg.developpez.com/tutoriels/delphi/firemonkey/FMXCorridor/src/FMXCorridor_src.zip

III - Le jeu

Le principe de jeu est le suivant : on se retrouve dans un tunnel en 3D et le joueur dispose d'une raquette (matérialisée par le cube blanc) qu'il dirige grâce à la souris. Une balle se trouve devant. Au début, cette balle part droit devant et revient vers le joueur lorsqu'elle rencontre un obstacle. Le joueur doit alors renvoyer la balle à l'aide la raquette. Si le joueur manque la balle, une « vie » est retranchée. Le tout se déroule alors qu'on avance dans le tunnel. L'objectif est d'arriver à la fin du tunnel avant d'avoir perdu toutes ses « vies ».

La version que je vous propose dans ce tutoriel est simplifiée : on se déplace à une vitesse constante vers le fond du tunnel et la raquette passe à travers les obstacles (ce qui n'est pas le cas dans le jeu original). Autre différence importante : dans mon tutoriel, je n'ai qu'un seul niveau jouable alors que le jeu d'origine est bien plus complet !

III-A - Construction d'un niveau

L'unité uNiveau.pas contient les classes TObstacle et TNiveauJeu.

TObstacle permet de définir les propriétés d'un obstacle. En plus de sa position et de sa taille, un obstacle pourra être animé.

TObstacle

// Informations sur un obstacle
TObstacle = class
fX, fY, fZ, fFinAnimation : single; // Position
fLargeur, fHauteur, fProfondeur, fDureeAnimation : single; // Taille
fAnimation : TAnimationObstacle; // Animation de l'obstacle

public
constructor Create; virtual;
property X : single read fX write fX;
property Y : single read fY write fY;
property Z : single read fZ write fZ;
property Largeur : single read fLargeur write fLargeur;
property Hauteur : single read fHauteur write fHauteur;
property Profondeur : single read fProfondeur write fProfondeur;
property Animation : TAnimationObstacle read fAnimation write fAnimation;
property DureeAnimation : single read fDureeAnimation write fDureeAnimation;
property FinAnimation : single read fFinAnimation write fFinAnimation;
end;

TNiveauJeu permet de définir les propriétés du niveau. Il sera ainsi possible de paramétrer le nom du niveau, la taille, la position de départ, celle d'arrivée, l'orientation de la balle au départ, la lumière, la vitesse de défilement, la vitesse de la balle et surtout le nombre d'obstacles composant le niveau.

TNiveauJeu contient également une procédure qui permet de créer les obstacles qui vont constituer le niveau.

TNiveauJeu

TObstacleList = TList<TObstacle>;

// Un niveau de jeu
TNiveauJeu = class
fNom : string;
fTaille, fFinNiveau : single;
fVitesseDefilement, fVitesseBalle : single;
FPositionBalleDepart : TPoint3D;
FDirectionBalleDepart : TPoint3D;
FNbNiveau : integer;
FCouleurLimere : cardinal;
FLumiereType : TLightType;

public
fNiveau : TObstacleList;
constructor Create; virtual;
destructor Destroy; override;
procedure CreationNiveau;
procedure InitialiserNiveau;
property Nom : String read fNom write fNom;
property Taille : Single read fTaille write fTaille;
property FinNiveau : single read fFinNiveau write fFinNiveau;
property VitesseDefilement : Single read fVitesseDefilement write fVitesseDefilement;
property VitesseBalle : Single read fVitesseBalle write fVitesseBalle;
property PositionBalleDepart : TPoint3D read FPositionBalleDepart write FPositionBalleDepart;
property DirectionBalleDepart : TPoint3D read FDirectionBalleDepart write FDirectionBalleDepart;
property NbNiveau : integer read FNbNiveau write FNbNiveau;
property CouleurLumiere : cardinal read FCouleurLimere write FCouleurLimere;
property TypeLumiere : TLightType read FLumiereType write FLumiereType;
end;

Le code présent dans cette unité ne comporte aucune difficulté. Vous pouvez modifier le niveau jouable fourni en ajoutant, supprimant, modifiant des obstacles dans la procédure CreationNiveau. À noter dans cette procédure l'utilisation de la directive {$IFDEF ANDROID} afin de positionner des valeurs (pour la vitesse de la balle et la vitesse de déplacement) propres à la plate-forme Android. En effet, sur mon téléphone, en laissant le même paramétrage que pour PC et Mac, le défilement est bien plus lent.

III-B - Interface graphique

Pour ce tutoriel, nous partirons d'un nouveau projet Delphi de type « Application multipériphérique » puis du modèle « Application vide ». Nous renommerons le projet et l'unité créés par défaut. Cette dernière s'appellera principale.pas.

Sur la fiche principale, nous déposons un composant TViewPort3D que nous nommons affichage3D. Il s'agit du composant de base pour gérer la 3D dans FMX. Il est aligné pour occuper tout l'espace : toute l'interface utilisera ce viewport.

Le projet sous l'IDE

L'image ci-dessus montre le concepteur graphique de Delphi avec le TViewport3D correspond à la scène de jeu. Par défaut, le TViewPort3D utilise une caméra par défaut qui permet de visualiser la scène 3D constituée à la souris. Nous lui associerons une autre caméra plus tard.

Par défaut, l'orientation est la suivante :

De plus, la caméra par défaut montre la scène avec une rotation de -20° sur l'axe X.

Sous ce TViewport3D, nous plaçons deux TDummy : dmyIntro contiendra les éléments affichés lors de la scène d'introduction, dmyScene contiendra la scène de jeu proprement dite.

La petite scène d'introduction ne présente pas de difficulté particulière : nous affichons le titre du jeu avec un effet de zoom, puis un effet de rotation. Lorsque l'utilisateur clique sur l'écran, nous passons à la scène « Menu » qui affiche un menu avec deux options : jouer et quitter. En cliquant sur le bouton « jouer », nous passons alors à la scène de jeu. C'est cette partie que nous allons détailler.

Nous l'avons vu précédemment, la structure du niveau sera construite dynamiquement dans le programme à l'aide de l'unité uNiveau.pas. En réalité, ce n'est pas toute la structure du niveau qui sera générée par uNiveau.pas.

En effet, uNiveau.pas ne servira qu'à générer les obstacles sur le parcours. Pour le reste, à savoir l'espace de jeu, la balle, la raquette du joueur ou encore les murs, nous préférons utiliser le concepteur graphique, car il permet de visualiser en temps réel le rendu.

Sur la capture ci-dessus, nous voyons la structure des composants utilisés.

Pour celles et ceux qui connaissent la VCL mais pas FMX, sous FMX, tous les composants peuvent être conteneurs. C'est très pratique ensuite, car si on déplace un composant parent, ses enfants vont suivre sans avoir à les gérer séparément.

Pour le moment, on peut ignorer les composants de type animation ou effet. Ils seront détaillés plus tard.

Le dmyScene est évidemment l'objet le plus important : tous les composants enfants qu'il contient constituent la scène de jeu 3D. Nous y retrouvons la TSphere (la balle), des TPlane pour les murs, sol, plafond et le fond du tunnel.

Nous y trouvons également un autre TDummy : dmyJoueur. Ce dernier va regrouper la raquette du joueur et la caméra. À l'exécution, le jeu n'utilisera pas la caméra par défaut mais celle-ci.

Autre objet important, le TStrokeCube (nommé zonejeu), est un rectangle en 3D, mais dont nous ne verrons que les arêtes. Il va représenter l'espace dans lequel la balle peut évoluer. Si la balle rencontre un de ses côtés, elle devra rebondir. Les TPlane vont être orientés et taillés pour correspondre aux côtés du TStrokeCube afin de l'habiller.

Enfin, il y a un TRectangle3D placé à côté de zonejeu. En fait, il s'agit de l'objet modèle pour les obstacles. Cet objet permettra de faire hériter les obstacles créés dynamiquement du rendu graphique du modèle. Ainsi, en mode conception, nous pouvons avoir un aperçu du rendu d'un obstacle (si nous modifions la texture ou la lumière, par exemple).

En plus des objets 3D, nous plaçons des TLayout afin d'afficher des informations au joueur :

- layInfos est aligné en bas de l'écran et permet d'afficher le nom du niveau et le nombre de « vies » disponibles ;

- layIntroMessage : permet juste d'afficher le message « Appuyer sur l'écran » lors de la scène d'introduction ;

- layMenu : permet d'afficher le menu ;

- layMessage : sert lors des scènes « Gagné » ou « Perdu » et permet d'afficher le message correspondant.

D'autres composants non visibles dans la capture d'écran de la structure sont présents. Il s'agit des materials. Nous avons à notre disposition trois types de material :

- TColorMaterialSource : il s'agit du plus simple des materials. Il permet d'appliquer une couleur à un objet 3D ;

- TtextureMaterialSource : il permet d'appliquer une texture à un objet 3D ;

- TLightMaterialSource : c'est le plus complexe. Il va réagir aux sources lumineuses éventuellement présentes dans la scène 3D. Nous pouvons ainsi définir une texture, des couleurs (ambiante, diffuse, en émission…).

Ces composants peuvent être appliqués aux objets 3D via leur propriété MaterialSource. Certains objets 3D (tels que le TRectangle3D) disposent de trois propriétés MaterialSource : nous pouvons alors leur appliquer des materials spécifiques pour la face avant, la face arrière et les côtés représentant la profondeur de l'objet.

Le rendu global de la scène est disponible dans le concepteur graphique de Delphi en temps réel !

III-C - Principes de fonctionnement général

Comme le tutoriel sur le jeu de Pong, plusieurs scènes sont prévues et elles seront affichées en fonction de la situation. Une variable déclarée publique et nommée jeu sera utilisée pour indiquer la scène à afficher. Jeu est de type TEtatJeu défini dans uNiveau.pas. Dans ce tutoriel, nous prévoirons cinq scènes différentes (introduction, menu, jeu, écran de défaite et écran de victoire).

Pour passer d'une scène à une autre, il suffira de modifier la valeur de cette variable et la prochaine itération de la boucle principale prendra en compte la scène à afficher.

Pour la boucle principale du jeu, nous choisirons un TfloatAnimation, car il est précis : nous pouvons en effet le stopper et reprendre comme nous le souhaitons de manière très simple. FMX fournit de nombreuses animations en tout genre qui peuvent s'avérer très utiles !

Comme c'est une boucle perpétuelle, nous positionnons à True sa propriété Loop. Sa propriété Enabled est à false : elle sera démarrée dans l'événement OnShow de la fiche fPrincipale.

Nous vérifions également que sa propriété Delay est à 0 (c'est le délai d'attente avant que l'animation ne débute après avoir appelé sa méthode start).

Dans son événement OnProcess, nous plaçons le code qui sera exécuté à chaque itération. Il s'agira tout simplement d'afficher la scène souhaitée en fonction de la variable jeu.

procedure TfPrincipale.aniPrincipaleProcess(Sender: TObject);
begin
// Rien de compliqué : en fonction de l'état de jeu, on affiche la scène correspondante
case jeu of
ejIntro : introduction;
ejMenu  : menu;
ejJeu   : actionJeu;
ejPerdu : afficherMessage('Perdu :(');
ejGagne : afficherMessage('Gagné :)');
end;
end;

III-D - Initialisation du jeu

Nous venons de voir l'utilité de la variable jeu. Deux autres variables publiques sont également définies : niveauJeu et nbVie.

La variable niveauJeu est de type TNiveauJeu et contiendra les éléments du niveau jouable et en particulier les obstacles.

La variable nbVie de type Integer correspond au nombre de tentatives restantes. Si ce compteur atteint zéro, la partie est perdue.

Nous initialisons tout cela dans l'événement FormCreate de la fiche fPrincipale.

// Initialisation de l'application
procedure TfPrincipale.FormCreate(Sender: TObject);
begin
jeu := ejIntro; // On force l'état de jeu pour afficher l'introduction au démarrage
raquetteJoueur.AutoCapture := true; // la raquette du joueur capture la souris sur le OnMouseDown
niveauJeu := TNiveauJeu.Create; // Création de l'objet qui contiendra le niveau jouable
ChargerNiveau; // charge le niveau jouable
end;

Il faut noter ici l'instruction raquetteJoueur.AutoCapture:= true ; qui permet de gérer facilement le lien souris/objet 3D cliqué.

La procédure ChargerNiveau va instancier le niveau (créer les obstacles, les positionner, les tailler, y associer des animations…).

//Chargement d'un niveau
procedure TfPrincipale.ChargerNiveau;
var
i : integer;
Obstacle : TProxyObject;
animation : TFloatAnimation;
begin
// On supprime l'éventuel niveau déjà chargé
for I := zoneJeu.ChildrenCount-1 downto 0 do
begin
if not(zoneJeu.Children[i] is TProxyObject) then continue;
Obstacle:=TProxyObject(zoneJeu.children[i]);
zoneJeu.RemoveObject(Obstacle);
FreeAndNil(Obstacle);
end;

niveauJeu.CreationNiveau; // On crée le niveau (le code est dans uJeu.pas)
initialiserPositionsDepart; // Initialisation des positions/orientations des objets

// Pour chaque Obstacle défini dans le niveau, on va instancier un objet FMX 3D correspondant en fonction
// des informations contenues dans le niveau
for I := 0 to niveauJeu.fNiveau.Count -1 do
begin
// On crée des TProxyObject
Obstacle := TProxyObject.Create(zoneJeu);
Obstacle.SourceObject := modeleObstacle; // en prenant pour modèle celui positionné en conception dans Delphi
Obstacle.Parent := zoneJeu; // L'obstacle aura pour parent la zone de jeu

// Positionnement et taille de l'obstacle
Obstacle.Position.x := niveauJeu.fNiveau[i].X;
Obstacle.Position.y := niveauJeu.fNiveau[i].Y;
Obstacle.Position.Z := niveauJeu.fNiveau[i].Z;
Obstacle.Height := niveauJeu.fNiveau[i].Hauteur;
Obstacle.Width := niveauJeu.fNiveau[i].Largeur;
Obstacle.Depth := niveauJeu.fNiveau[i].Profondeur;
// Affectation de l'événement OnRender (permettra de gérer les interactions avec la balle)
Obstacle.OnRender := ObstacleRender;
Obstacle.HitTest := false; // pour éviter qu'en cliquant, on sélectionne un obstacle (seule la raquette du joueur sera sélectionnable)

// Création des animations pour les obstacles animés
case niveauJeu.fNiveau[i].Animation of
aoHorizontal :
begin
animation := TFloatAnimation.Create(Obstacle);
animation.PropertyName := 'Position.X';
animation.StartValue := Obstacle.Position.X;
animation.StopValue := niveauJeu.fNiveau[i].FinAnimation;
animation.Loop := true;
animation.AutoReverse := true;
animation.Duration := niveauJeu.fNiveau[i].DureeAnimation;
animation.Parent := Obstacle;
animation.Start;
end;
aoVertical :
begin
animation := TFloatAnimation.Create(Obstacle);
animation.PropertyName := 'Position.Y';
animation.StartValue := Obstacle.Position.Y;
animation.StopValue := niveauJeu.fNiveau[i].FinAnimation;
animation.Loop := true;
animation.AutoReverse := true;
animation.Duration := niveauJeu.fNiveau[i].DureeAnimation;
animation.Parent := Obstacle;
animation.Start;
end;
end;
end;
end;

ChargerNiveau commence par supprimer les objets (obstacles) éventuellement déjà présents (en prévision de la création de niveaux supplémentaires, cette procédure pourra réinitialiser les obstacles et ne pas simplement en rajouter).

Nous appelons ensuite la méthode CreationNiveau qui permet de créer les obstacles du niveau et de positionner certains paramètres propres au niveau (positions de départ et d'arrivée par exemple). Pour créer d'autres niveaux, il faudra prévoir d'autres méthodes dans uNiveau.pas en prenant pour exemple cette méthode CreationNiveau.

Ensuite, pour chaque obstacle instancié, nous créons réellement l'objet 3D associé. Nous utilisons pour cela un TProxyObjet qui aura pour source le TRectangle3D servant de modèle (pour tout ce qui est rendu graphique) et nous plaçons cet objet comme enfant de l'objet zonejeu.

Chose importante, nous affectons un événement OnRender : cet événement sera déclenché par FMX à chaque fois qu'un rendu graphique sera demandé. C'est dans cet événement que nous détecterons si la balle est entrée en collision avec l'obstacle ou la raquette du joueur.

Nous en profitons pour mettre à false la propriété HitTest de l'obstacle afin d'éviter que l'utilisateur ne puisse sélectionner un obstacle lors d'un clic de souris (nous souhaitons que seule la raquette accepte le clic).

Enfin, nous créons et affectons à l'obstacle une éventuelle animation.

III-E - Interaction de l'utilisateur avec la raquette

Dans le jeu, la seule action que peut faire l'utilisateur, c'est de déplacer la raquette. Pour ce faire, il devra cliquer dessus et la déplacer tout en maintenant le bouton gauche de la souris enfoncé.

La solution à ce problème est d'agir lors du clic sur la raquette avec l'événement OnMouseDown :

// Quand on clique sur la raquette
procedure TfPrincipale.raquetteJoueurMouseDown(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Single; RayPos, RayDir: TVector3D);
begin
// L'utilisateur clique sur sa raquette, on récupère la position de la raquette
if ssLeft in Shift then
begin
with TControl3d(Sender).Position do DefaultValue:= Point - TPoint3D((RayDir * RayPos.length)) * Point3D(1,0,0);
end;
end;

Puis, lorsqu'il déplace la souris (événement OnMouseMove) tout en maintenant le bouton gauche enfoncé, nous agirons ainsi :

// Déplacement de la raquette
procedure TfPrincipale.raquetteJoueurMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Single; RayPos, RayDir: TVector3D);
begin
affichage3D.BeginUpdate;
// L'utilisateur maintient le bouton gauche de la souris enfoncé et déplace la souris, alors on déplace sa raquette
if ssLeft in Shift then
begin
with TControl3D(sender).Position do
begin
// Nouvelle position de la raquette du joueur
Point := DefaultValue + TPoint3D(RayDir *RayPos.length) * Point3D(1,1,0);
end;
end;
affichage3D.EndUpdate;
end;

Enfin, lorsqu'il relâche le bouton gauche de la souris (OnMouseUp), nous repositionnons automatiquement la raquette au centre (merci les animations de FMX !) :

// On lâche la raquette, on la fait revenir au centre de l'écran
procedure TfPrincipale.raquetteJoueurMouseUp(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Single; RayPos, RayDir: TVector3D);
begin
TAnimator.AnimateFloat(raquetteJoueur,'Position.X',0);
TAnimator.AnimateFloat(raquetteJoueur,'Position.Y',0);
end;

III-F - Gestion des collisions de la balle avec les obstacles

Nous entrons dans la partie la plus difficile de ce tutoriel. Nous avons vu précédemment que la gestion des collisions de la balle avec les obstacles sera réalisée dans l'événement OnRender de chaque obstacle et la raquette du joueur. Mais avant de voir le code, voyons quelques petites explications.

La détection des collisions entre deux objets en 3D que nous allons utiliser est simplifiée. En effet, vous aurez remarqué que les objets sont très simples : des rectangles (obstacles et raquette) et une sphère (la balle). Cela va simplifier les choses !

De plus, nous simplifions encore un peu en considérant non pas la sphère elle-même, mais le cube imaginaire englobant la sphère de sorte que :

- un côté du cube mesure le diamètre de la sphère ;

- les centres (barycentres) du cube et de la sphère soient au même endroit.

Petite représentation du cube "imaginaire"

En adoptant ce principe, la détection de collision ne correspondra pas tout à fait à la réalité : nous détecterons une collision si un coin du cube touche l'obstacle alors qu'en réalité la sphère n'a pas été touchée… mais nous nous contenterons de cette approximation pour le tutoriel:)

Une chose supplémentaire à comprendre est que, lorsque nous créons un objet 3D sous Delphi, nous disposons de sa propriété Position qui permet de positionner l'objet sur la scène 3D en fonction des trois coordonnées X, Y et Z. Ces coordonnées correspondent à la position du barycentre de l'objet. Ensuite lorsque nous taillons l'objet en jouant sur sa largeur, sa hauteur et sa profondeur, ces dernières se répartissent équitablement dans chaque direction autour du barycentre.

Sur ce schéma, le centre G du TRectangle3D est placé à une certaine position. Ensuite, nous taillons l'objet en jouant sur sa largeur L (width), hauteur H (height) et profondeur P (depth).

Exemple : si le centre G est à la position X = 0, Y=0 et Z= 0, et que nous fixons la largeur à 4, alors le rectangle s'étendra sur l'axe X de -2 à +2.

Je ne vous ai pas perdu ? Alors, continuons !

Voyons comment détecter une collision entre deux objets. Nous allons déterminer la distance minimale entre les deux centres et ce sur les trois axes X, Y et Z. Or, nous connaissons les positions des centres des deux objets et leurs tailles.

Suite au schéma que nous venons de voir, nous constatons que le centre de l'objet est toujours situé à la moitié de la largeur, la moitié de la hauteur et la moitié de la profondeur.

Voici le même schéma que précédemment, mais complété par les projections orthogonales du centre sur les faces. Nous constatons que chaque projection mesure la moitié de la taille correspondante (les flèches vertes).

Passons temporairement en 2D pour schématiser une collision :

Le rectangle de centre G1 et le cercle de centre G2 se touchent lorsque la distance entre G1 et G2 est la moitié de la largeur du rectangle à laquelle nous ajoutons la moitié du diamètre du cercle (c'est-à-dire son rayon).

C'est le même principe sur les trois axes d'un monde en 3D.

Les bases étant posées, voici le code correspondant : vous constaterez que cela n'est pas bien compliqué !

// OnRender des différents objets obstacles
procedure TfPrincipale.ObstacleRender(Sender: TObject; Context: TContext3D);
var
unObjet3D:TControl3D; // l'objet en cours de rendu
DistanceEntreObjets,Direction,distanceMinimum: TPoint3D;
begin
unObjet3D := TControl3D(sender); // On travaille sur l'objet qui est en train d'être calculé
DistanceEntreObjets := unObjet3D.AbsoluteToLocal3D(TPoint3D(Balle.AbsolutePosition)); // Distance entre l'objet 3d et la balle
distanceMinimum := (SizeOf3D(unObjet3D) + SizeOf3D(Balle)) / 2; // distanceMinimum : on divise par 2 car le centre de l'objet est la moitié de la taille de l'élément sur les 3 composantes X, Y, Z

// Test si la valeur absolue de position est inférieure à la distanceMinimum calculée sur chacune des composantes
if ((Abs(DistanceEntreObjets.X) < distanceMinimum.X) and (Abs(DistanceEntreObjets.Y) < distanceMinimum.Y) and
(Abs(DistanceEntreObjets.Z) < distanceMinimum.Z)) then
begin
// Si c'est le cas, c'est qu'il y a collision
Direction := Balle.Position.Point - unObjet3D.Position.Point; // On calcule la nouvelle direction de la balle
Balle.Position.DefaultValue := Direction; // On affecte la nouvelle direction
if unObjet3D.tag = 1 then aniCouleurContact.Start // petit plus si la propriété tag de l'objet est 1 (seule la raquette du joueur a le tag à 1), alors on lance l'animation de couleur pour faire clignoter en vert la raquette
end;
end;

La fonction SizeOf3D renvoie un TPoint3D (un point défini avec ses trois coordonnées) :

// Renvoie les dimensions de l'objet 3D
function TfPrincipale.SizeOf3D(const unObjet3D: TControl3D): TPoint3D;
begin
Result :=NullPoint3D;
if unObjet3D <> nil then
result := Point3D(unObjet3D.Width, unObjet3D.Height, unObjet3D.Depth);
end;

La variable DistanceEntreObjets est un TPoint3D qui correspondra à la distance réelle entre les centres des deux objets.

Le calcul de la distance minimum entre les deux objets se fait en additionnant les dimensions des deux objets et en divisant par 2 pour obtenir la moitié. Le fait d'additionner les deux TPoint3D va permettre de jouer sur les trois axes en un seul coup !

Pour détecter la collision, il suffit de comparer chaque composante (X, Y et Z) de la variable distanceEntreObjets avec la composante correspondante de distanceMinimum. Petit détail important, nous prenons la valeur absolue des composantes de distanceEntreObjets, car il peut y avoir des valeurs négatives ou positives en fonction de la provenance de la balle.

Voilà, la détection d'une collision entre les deux objets est opérationnelle. Maintenant, il faut réagir à cette collision !

Voici le code complet de l'événement OnRender :

// OnRender des différents objets obstacles
procedure TfPrincipale.ObstacleRender(Sender: TObject; Context: TContext3D);
var
unObjet3D:TControl3D; // l'objet en cours de rendu
DistanceEntreObjets,Direction,distanceMinimum: TPoint3D;
begin
unObjet3D := TControl3D(sender); // On travaille sur l'objet qui est en train d'être calculé

DistanceEntreObjets := unObjet3D.AbsoluteToLocal3D(TPoint3D(Balle.AbsolutePosition)); // Distance entre l'objet 3d et la balle

distanceMinimum := (SizeOf3D(unObjet3D) + SizeOf3D(Balle)) / 2; // distanceMinimum : on divise par 2 car le centre de l'objet est la moitié de la taille de l'élément sur les 3 composantes X, Y, Z

// Teste si la valeur absolue de position est inférieure à la distanceMinimum calculée sur chacune des composantes
if ((Abs(DistanceEntreObjets.X) < distanceMinimum.X) and (Abs(DistanceEntreObjets.Y) < distanceMinimum.Y) and
(Abs(DistanceEntreObjets.Z) < distanceMinimum.Z)) then
begin
// Si c'est le cas, c'est qu'il y a collision
Direction := Balle.Position.Point - unObjet3D.Position.Point; // On calcule la nouvelle direction de la balle
Balle.Position.DefaultValue := Direction; // On affecte la nouvelle direction
if unObjet3D.tag = 1 then aniCouleurContact.Start; // petit plus si la propriété tag de l'objet est 1 (seule la raquette du joueur a le tag à 1), alors on lance l'animation de couleur pour faire clignoter en vert la raquette
end;
end;

La réaction à la collision se fait simplement : nous soustrayons la position de l'obstacle à la position de la balle, ce qui va donner la nouvelle direction de la balle. Nous affectons cette nouvelle direction à la balle.

Un petit plus : si la propriété tag de l'objet est à 1 (seule la raquette du joueur a un tag à 1), nous déclenchons une animation qui va coloriser la raquette en vert pendant 0,2 seconde (c'est la durée de l'animation par défaut si nous n'en précisons pas la durée). Cela permet de visualiser le contact entre la balle et la raquette.

III-G - Gestion du déplacement de la balle

La procédure DeplacementBalle va gérer le déplacement de la balle dans le TStrokeCube zonejeu. C'est donc dans cette procédure que nous allons nous occuper des collisions de la balle et de l'espace dans lequel elle est autorisée à se déplacer.

Comme nous avons placé des TPlane sur les côtés de zonejeu, nous aurons ainsi l'impression que la balle rebondit contre les murs, le plafond, le sol et le fond !

// Gestion du déplacement de la balle
procedure TfPrincipale.DeplacementBalle;
var
DirectionBalle, DistanceEntreObjets, distanceMinimum:TPoint3D;
begin
DirectionBalle:=Balle.Position.DefaultValue;
DistanceEntreObjets:=zoneJeu.AbsoluteToLocal3D(TPoint3D(Balle.AbsolutePosition));

distanceMinimum:=(SizeOf3D(zoneJeu) - SizeOf3d(Balle)) / 2;

if ((DistanceEntreObjets.X > distanceMinimum.X) and (DirectionBalle.X > 0)) or (( DistanceEntreObjets.X < -distanceMinimum.X) and (DirectionBalle.X < 0))  then  DirectionBalle.X := -DirectionBalle.x;
if ((DistanceEntreObjets.Y > distanceMinimum.Y) and (DirectionBalle.Y > 0)) or (( DistanceEntreObjets.Y < -distanceMinimum.Y) and (DirectionBalle.Y < 0))  then  DirectionBalle.Y := -DirectionBalle.Y ;
if ((DistanceEntreObjets.Z > distanceMinimum.Z) and (DirectionBalle.Z > 0)) or (( DistanceEntreObjets.Z < -distanceMinimum.Z) and (DirectionBalle.Z < 0))  then  DirectionBalle.Z := -DirectionBalle.Z;

// Nouvelle position
Balle.Position.Point := Balle.Position.Point + DirectionBalle.Normalize * niveauJeu.vitesseBalle;
Balle.Position.DefaultValue:=DirectionBalle;
ombre.Position.X := balle.Position.x;
ombre.Position.Z := balle.Position.z;
end;

Le principe est le même que la détection des collisions entre la balle et les obstacles, sauf que là, nous sommes dans l'objet zonejeu.

III-H - Animation

Comme vu précédemment, la boucle principale va afficher telle ou telle scène en fonction de la variable jeu. Nous n'allons détailler que la scène de jeu. Les autres ne sont pas compliquées et, de toute façon, vous avez tout dans les sources du projet fournis avec ce tutoriel.

Dans la boucle principale, lorsque la scène de jeu doit être affichée, nous invoquons la procédure actionJeu.

procedure TfPrincipale.ActionJeu;
begin
// Masque/Affiche les composants pour la scène à afficher
affichageScene;

// Le déplacement est effectué sur l'axe Z et on déplace le dmyJoueur (la caméra lui étant rattachée)
dmyJoueur.Position.Z := dmyJoueur.Position.Z + niveauJeu.VitesseDefilement;
// Procédure qui gère le mouvement de la balle
DeplacementBalle;

// Si le dmyJoueur atteint la position (sur l'axe Z) définie dans le niveau comme étant la position d'arrivée,
// alors c'est gagné : on passe le jeu à l'état ejGagne !
if dmyJoueur.Position.Z > niveauJeu.FinNiveau then jeu := ejGagne;

// La balle est passée derrière la raquette du joueur : on perd une "vie"...
if Balle.Position.Z < dmyJoueur.Position.Z -1 then
begin
// Suppression d'un cercle
if Circle1.Visible then Circle1.Visible := false
else
begin
if Circle2.Visible then Circle2.Visible := false
else
begin
if Circle3.Visible then Circle3.Visible := false;
end;
end;
// On décrémente le compteur de vies
dec(nbVie);
// On replace la bille devant la raquette du joueur
Balle.position.Z := dmyJoueur.Position.Z + 0.1;
Balle.position.Y := 0;
Balle.position.X := 0;
// On redonne à la balle la direction d'origine (définie dans le niveau)
Balle.Position.DefaultValue := niveauJeu.DirectionBalleDepart;
aniPrincipale.stop;
DissolveTransitionEffect1.Progress := 100;
aniTransition.start;
end;

// Si le compteur de vies est à 0, c'est perdu : on passe le jeu à l'état correspondant
if nbVie = 0 then jeu := ejPerdu;

C'est donc dans cette procédure que nous faisons appel à la méthode DeplacementBalle vue précédemment.

Vous remarquez également l'appel à la méthode affichageScene. Celle-ci permet d'afficher ou de masquer les composants que nous souhaitons afficher en fonction de la scène à afficher. C'est elle également qui permet de faire basculer le point de vue du affichage3D : pour les scènes d'introduction et de menu, nous utilisons la caméra par défaut du affichage3D (affichage3D.UsingDesignCamera à true). Pour les autres scènes, nous utilisons la caméra associée au dmyJoueur (affichage3D.UsingDesignCamera à false) et affectée à la propriété camera du affichage3D.

À noter également, l'instruction

dmyJoueur.Position.Z:= dmyJoueur.Position.Z + niveauJeu.VitesseDefilement ;

C'est elle qui permet de déplacer le TDummy représentant la raquette du joueur et son point de vue (la caméra).

Si la raquette du joueur atteint la position d'arrivée spécifiée dans le niveau, alors nous affichons l'écran de victoire.

Nous contrôlons également la position de la balle par rapport à la raquette du joueur. Si celle-ci est derrière, nous faisons perdre une « vie » au joueur, nous stoppons la boucle principale le temps de rejouer l'animation de transition (afin que le joueur se rende compte qu'il vient de perdre un essai) et nous replaçons la balle devant lui dans la bonne direction.

Si le joueur n'a plus de « vie », nous demandons l'affichage de la scène de défaite.

IV - Les petits plus

Dans ce tutoriel, je me suis focalisé sur les points les plus importants du projet. Vous constaterez que le code source du projet lié au tutoriel est plus complet.

Je vous laisse découvrir les autres petites scènes et les animations FMX que j'ai utilisées. Si vous avez des questions, je vous invite à les poser dans les commentaires de ce tutoriel : si vous vous les posez, d'autres personnes doivent se les poser :).

V - Améliorations futures

Ce tutoriel est une base et, bien entendu, il est possible d'améliorer grandement le jeu. Vous pouvez reprendre cette base et la modifier comme vous le souhaitez.

À titre personnel, je risque d'apporter des évolutions, mais le projet source fourni sur le site de Développez ( https://gbegreg.developpez.com/tutoriels/delphi/firemonkey/FMXCorridor/src/FMXCorridor_src.zip) avec ce tutoriel n'évoluera pas pour rester en accord avec le tutoriel.

La version du projet qui risque d'évoluer est celle que je placerai sur mon GitHub :https://github.com/gbegreg/

VI - Remerciements

Je vous remercie d'avoir lu ce tutoriel jusqu'au bout et j'espère qu'il vous a plu.

Merci aux relecteurs et correcteurs de ce tutoriel : SergioMaster, Gaby277, GVasseur58 et ClaudeLELOUP.

Enfin, je remercie l'équipe de Developpez.com pour son travail.