Un jeu de serpent en Pascal

Réalisation d'un jeu de serpent en Pascal

Roland Chastain

Je vous propose d'écrire un jeu de serpent en Pascal.

Un serpent, contrôlé par le joueur au moyen des flèches du clavier, se déplace sur l'écran, sans jamais pouvoir s'arrêter, et mange des pommes. Est-ce une réminiscence de l'histoire de nos premiers parents, du fruit défendu, de la première tentation et de la première action contraire à la volonté du Créateur ? Je ne saurais le dire. Toujours est-il que dans la plupart des versions de ce jeu que j'ai vues, le serpent, au mépris des données les plus constantes de l'histoire naturelle, — pour ne pas dire de l'herpétologie, — le serpent disais-je mange des pommes. À chaque fois que le serpent mange une pomme, il grandit. La partie est terminée quand le serpent heurte un mur ou son propre corps.

Dans la première partie de cet article, je propose une façon d'écrire l'intérieur (pour ainsi dire) du jeu, à savoir le serpent, son déplacement et les différents événements du jeu, indépendamment de leur représentation visuelle.

Dans la seconde partie, je montre l'utilisation de l'unité WinGraph pour ouvrir une fenêtre graphique et y dessiner notre serpent.

Commentez

- Titre : Un jeu de serpent en Pascal

- Auteur : Roland Chastain

- Parution : 20 mars 2018

- Mise à jour : 20 mars 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 Roland Chastain. 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. Le serpent.

- I-A. Le type TPoint de l'unité Classes

- I-B. Mouvement du serpent

- II. Création d'une fenêtre graphique avec l'unité WinGraph

- II-A. Création de la fenêtre

- II-B. Dessiner dans une partie de la zone client

- II-C. Dessin d'un carré

- II-D. Fermeture de la fenêtre

- III. Tableau des meilleurs scores

- IV. Compilation

- IV-A. Compilation du fichier de ressources

- IV-B. Compilation du programme

- IV-C. Astuce pour un programme en plusieurs langues

- V. Fabrication d'une icône à partir d'une image au format PNG

- VI. Conclusion

I - Le serpent.

Commençons par donner corps au serpent.

I-A - Le type TPoint de l'unité Classes

J'ai choisi d'utiliser le type TPoint de l'unité Classes pour représenter le serpent, les pommes et la direction courante du serpent.

Le type TPoint sert à définir un point par ses coordonnées. Voici la déclaration de ce type telle que je l'ai trouvée dans le code source de Free Pascal :

1. { TYPSHRDH.inc }

3. TPoint  = record

4. x : Longint;

5. y : Longint;

6. public

7. constructor Create(ax,ay:Longint); overload;

8. constructor Create(apt :TPoint); overload;

9. class function Zero: TPoint; static; inline;

10. function Add(const apt: TPoint): TPoint;

11. function Distance(const apt: TPoint) : ValReal;

12. function IsZero : Boolean;

13. function Subtract(const apt : TPoint): TPoint;

14. procedure SetLocation(const apt :TPoint);

15. procedure SetLocation(ax,ay : Longint);

16. procedure Offset(const apt :TPoint);

17. procedure Offset(dx,dy : Longint);

18. class function PointInCircle(const apt, acenter: TPoint; const aradius: Integer): Boolean; static; inline;

19. class operator = (const apt1, apt2 : TPoint) : Boolean;

20. class operator <> (const apt1, apt2 : TPoint): Boolean;

21. class operator + (const apt1, apt2 : TPoint): TPoint;

22. class operator - (const apt1, apt2 : TPoint): TPoint;

23. class operator := (const aspt : TSmallPoint) : TPoint;

24. class operator Explicit (Const apt : TPoint) : TSmallPoint;

25. end;

C'est ce qu'on appelle un enregistrement étendu, c'est-à-dire incluant des méthodes qui permettent d'agir sur les données, en l'occurrence les coordonnées d'un point.

Notre serpent sera essentiellement un ensemble de points. Ce sera plus exactement une variable de type array of TPoint, c'est-à-dire un tableau d'enregistrements étendus du type TPoint.

1. { SNAKEGAME.pas }

3. vSnake: array of TPoint;

La longueur du serpent sera modifiée pendant l'exécution du programme. Au début notre tableau sera dimensionné de façon à pouvoir contenir deux éléments, disons la tête et la queue du serpent, sans lesquelles (vous en conviendrez) il n'est pas d'animal rampant digne de ce nom :

1. const

2. cInitialLength = 2;

3. begin

4. SetLength(vSnake, cInitialLength);

Le premier élément du tableau (vSnake[0]) sera la tête du serpent. Nous utiliserons la bien nommée procédure SetLocation pour attribuer une valeur à cet élément, autrement dit pour placer la tête du serpent :

1. vSnake[0].SetLocation(5, 5);

Notez bien que les points ne correspondent pas directement à des positions sur l'écran ou sur la fenêtre de l'application, mais à des cases comme celles d'un damier. C'est au moment de dessiner le jeu que nous nous soucierons de convertir les coordonnées des points.

La pomme sera une simple variable de type TPoint, la direction du serpent également.

Pour initialiser et modifier la direction du serpent, nous utiliserons un tableau de points :

1. const

2. DIRECTIONS: array[0..3] of TPoint = (

3. (x:  0; y: +1),

4. (x: +1; y:  0),

5. (x:  0; y: -1),

6. (x: -1; y:  0)

7. );

9. var

10. vDirection: TPoint;

11. vDirectionIndex: integer;

13. begin

14. vDirectionIndex := 1;

15. vDirection := DIRECTIONS[vDirectionIndex];

La position initiale de la queue sera calculée en fonction de la position de la tête et de la direction du serpent. Nous utiliserons pour ce faire la fonction Substract du type TPoint, en combinaison avec la procédure SetLocation :

1. vSnake[1].SetLocation(vSnake[0].Subtract(vDirection));

Nous pourrions aussi bien utiliser (comme nous l'avons fait plus haut pour initialiser la variable vDirection) l'opérateur := :

1. vSnake[1] := vSnake[0].Subtract(vDirection);

Ou comme avec un enregistrement classique :

1. snake[1].x := snake[0].x - direction.x;

2. snake[1].y := snake[0].y - direction.y;

Ou encore utiliser le constructeur Create, qui fait exactement la même chose que la procédure SetLocation, à ceci près qu'on peut aussi l'utiliser de la façon suivante, comme si on créait une instance d'une classe :

1. vSnake[1] := TPoint.Create(vSnake[0].Subtract(vDirection));

Ce qui est la même chose que :

1. vSnake[1].Create(vSnake[0].Subtract(vDirection));

Bon, vous avez compris le principe : passons à la suite. Si vous voulez tout savoir au sujet des enregistrements étendus, je vous recommande la lecture de cet article.

I-B - Mouvement du serpent

À présent, mettons notre serpent en mouvement.

Chaque fois qu'un certain intervalle de temps se sera écoulé, le programme principal appellera la procédure suivante :

1. procedure TSnakeGame.MoveSnake;

2. begin

3. { Déplacer le corps du serpent en fonction de la position antérieure de la tête. }

4. Move(vSnake[0], vSnake[1], High(vSnake) * SizeOf(TPoint));

6. { Déplacer la tête en fonction de la direction courante. }

7. vSnake[0].Offset(vDirection);

9. { Si la tête est sortie de la surface de jeu, la téléporter de l'autre côté. Utile seulement si la

10. téléportation est autorisée, autrement dit si la fonction Collision a été appelée avec la valeur

11. FALSE, c'est-à-dire en mode "sans murs". }

12. if vSnake[0].x > XMAX then vSnake[0].x := XMIN;

13. if vSnake[0].x < XMIN then vSnake[0].x := XMAX;

14. if vSnake[0].y > YMAX then vSnake[0].y := YMIN;

15. if vSnake[0].y < YMIN then vSnake[0].y := YMAX;

16. end;

C'est-à-dire que chaque partie du serpent (à l'exception de la tête) prendra la position de la partie qu'elle suit. Quant à la tête, sa nouvelle position dépendra de la direction choisie par le joueur. Vous aurez remarqué l'emploi de la procédure Offset pour déplacer le point en fonction d'une direction elle-même représentée par un point (un vecteur si vous préférez).

Avant chaque déplacement du serpent, il faudra vérifier que la case vers laquelle le serpent se dirige n'est pas occupée ou par le serpent lui-même ou par une pomme. Pour ce faire, nous aurons besoin des fonctions suivantes :

1. function TSnakeGame.IsSnake(const aPoint: TPoint): boolean;

2. var

3. vTail, vSnakePart: integer;

4. begin

5. vTail := High(vSnake);

6. vSnakePart := 1;

7. result := FALSE;

8. while (vSnakePart <= vTail) and not result do

9. begin

10. //result := PointsEqual(aPoint, vSnake[vSnakePart]);

11. result := aPoint = vSnake[vSnakePart];

12. Inc(vSnakePart);

13. end;

14. end;

16. function TSnakeGame.IsApple(const aPoint: TPoint): boolean;

17. begin

18. //result := PointsEqual(aPoint, vApple);

19. result := aPoint = vApple;

20. end;

La fonction PointsEqual fait double emploi avec l'opérateur surchargé =. On peut utiliser l'un ou l'autre indifféremment.

Je crois que j'en ai dit assez sur ce sujet. Passons à la partie visible du programme, à la fenêtre dans laquelle le serpent sera dessiné.

II - Création d'une fenêtre graphique avec l'unité WinGraph

L'unité WinGraph de Stefan Berinde a la même fonction que l'unité Graph de l'antique Turbo Pascal, qu'elle est faite pour remplacer. Cette unité va donc nous permettre d'ouvrir une fenêtre graphique et d'y dessiner. L'unité WinCrt qui accompagne l'unité WinGraph nous servira à savoir sur quelles touches l'utilisateur appuie.

On pourrait aussi bien utiliser les unités ptcGraph et ptcCrt livrées avec Free Pascal : le programme pourrait alors tourner sous Linux. Cependant j'aime bien l'unité WinGraph, ne serait-ce qu'à cause de sa documentation à la fois courte et complète.

Tout le code faisant appel aux fonctions de l'unité WinGraph, pour créer la fenêtre de l'application et y dessiner, est regroupé dans une unité que nous appellerons (sauf si vous avez une meilleure idée) GRAPHICS.pas.

Voici à quoi ressemble l'écran ou la page principale du jeu :

Il y aura aussi deux autres écrans ou pages : une page d'accueil et une page pour l'affichage des meilleurs scores.

II-A - Création de la fenêtre

La fenêtre est dimensionnée sur mesure en fonction de notre « zone client », laquelle se compose du champ ou de la grille dans laquelle le serpent se déplace et de deux rectangles dans lesquels nous écrirons du texte.

Nous utilisons les procédures SetWindowSize (pour choisir les dimensions de la fenêtre) et InitGraph (pour créer la fenêtre).

1. { GRAPHICS.pas }

3. procedure OpenWindow;

4. var

5. vDriver, vMode: smallint;

6. begin

7. SetWindowSize(vFieldHeight, 2 * vLabelHeight + vFieldHeight);

8. vDriver := NoPalette;

9. vMode := mCustom;

10. InitGraph(vDriver, vMode, GAME_TITLE);

11. vFont := InstallUserFont('Verdana');

12. UpdateGraph(UpdateOff);

13. end;

Dans la même procédure, nous choisissons la police de caractères pour l'affichage du texte. Enfin, nous appelons la procédure UpdateGraph avec le paramètre UpdateOff. De cette façon, l'écran de la fenêtre ne sera repeint que lorsque nous le déciderons, et non pas après chaque opération de dessin (ce qui est le comportement par défaut).

Voici le code pour demander le rafraîchissement de la fenêtre :

1. UpdateGraph(UpdateNow);

De cette façon la fenêtre pourra être rafraîchie le moins possible : c'est-à-dire une fois que nous aurons déplacé le serpent, replacé la pomme (éventuellement), modifié le score, etc. Il est important de bien faire cette distinction entre mémoire et image à l'écran. Généralement, un seul affichage suffit pour plusieurs modifications de l'image en mémoire.

Nous nous garderons donc de laisser l'écran se rafraîchir après chaque opération de dessin. Nous nous garderons aussi de tout redessiner à chaque fois. Il ne faut redessiner que le nécessaire. Nous allons voir, par exemple, il n'est pas nécessaire de redessiner à chaque fois le serpent tout entier.

II-B - Dessiner dans une partie de la zone client

La zone client de notre fenêtre se compose de trois rectangles superposés : une barre pour afficher du texte, le carré dans lequel le serpent se déplace, et une autre barre destinée à recevoir du texte.

Pour dessiner aisément dans chacune de ces zones, nous utiliserons systématiquement la procédure SetViewPort, qui permet de traiter séparément un rectangle à l'intérieur de la fenêtre.

Nous n'appellerons pas la procédure SetViewPort directement, mais par l'intermédiaire d'une autre procédure qui, en fonction de la zone de dessin concernée, règlera aussi les couleurs et la hauteur du texte. Toutes ces données seront regroupées dans un enregistrement étendu dont nous déclarons préalablement le type.

1. type

2. TDrawContext = record

3. vTop, vBottom, // Coordonnées de la zone de dessin.

4. vTextSize: integer; // Hauteur du texte.

5. vColor, vBkColor: longword; // Couleurs (avant-plan, arrière-plan).

6. constructor Create(const aTop, aBottom, aTextSize: integer; const aColor, aBkColor: longword);

7. end;

9. constructor TDrawContext.Create(const aTop, aBottom, aTextSize: integer; const aColor, aBkColor: longword);

10. begin

11. vTop := aTop;

12. vBottom := aBottom;

13. vTextSize := aTextSize;

14. vColor := aColor;

15. vBkColor := aBkColor;

16. end;

18. var

19. vContextArray: array[TScreenArea] of TDrawContext;

21. procedure SetDrawingContext(const aScreenArea: TScreenArea; const aClearViewPort: boolean);

22. begin

23. with vContextArray[aScreenArea] do

24. begin

25. SetViewPort(0, vTop, vFieldHeight - 1, vBottom, ClipOff); // Définition de la zone de dessin.

26. SetColor(vColor); // Choix de la couleur d'avant-plan.

27. SetBkColor(vBkColor); // Choix de la couleur d'arrière-plan.

28. SetTextStyle(vFont or BoldFont, HorizDir, vTextSize); // Choix de la hauteur du texte.

29. if aClearViewPort then

30. ClearViewPort; // Effacement de la zone de dessin.

31. end;

32. end;

La procédure ClearViewPort permet de n'effacer qu'une zone choisie, à la différence de la procédure ClearDevice qui efface toute la fenêtre.

II-C - Dessin d'un carré

Pour que l'animation soit fluide, il faut faire la chasse aux opérations inutiles, qui ne se voient pas, mais donnent du travail à la machine.

C'est pourquoi nous nous garderons bien d'effacer et de redessiner toute la zone client (même en mémoire) à chaque déplacement du serpent. Il suffira de dessiner la tête et d'effacer la queue. Quand le serpent aura avalé une pomme, seule la première opération sera nécessaire.

Nous utiliserons une seule et même procédure pour dessiner le serpent, les pommes et aussi pour effacer. C'est l'avantage de tout faire avec des carrés !

1. procedure DrawPoint(const aSquare: TPoint; const aColor: TPointColor; const aUpdateScreen: boolean);

2. var

3. x, y: integer;

4. vColor, vFillColor: longword;

5. begin

6. case aColor of

7. colorSnake:

8. begin

9. vColor := LimeGreen;

10. vFillColor := Green;

11. end;

12. colorApple:

13. begin

14. vColor := Red;

15. vFillColor := DarkRed;

16. end;

17. colorBackground:

18. begin

19. vColor := Black;

20. vFillColor := Black;

21. end;

22. end;

23. SetColor(vColor); // Couleur des bords du carré.

24. SetFillStyle(SolidFill, vFillColor); // Couleur de remplissage du carré.

25. x := vSquareWidth * (aSquare.x - 1);

26. y := vFieldHeight - (vSquareWidth * aSquare.y);

27. FillRect(x + 1, y + 1, x + vSquareWidth - 1, y + vSquareWidth - 1); // Dessin du carré.

28. if aUpdateScreen then

29. UpdateGraph(UpdateNow); // Rafraîchissement de l'écran.

30. end;

II-D - Fermeture de la fenêtre

Pour fermer la fenêtre, nous utiliserons la procédure CloseGraph :

1. procedure CloseWindow;

2. begin

3. CloseGraph;

4. end;

Pour savoir si l'utilisateur cherche à fermer la fenêtre, nous appellerons la fonction CloseGraphRequest :

1. function CloseRequest: boolean;

2. begin

3. result := CloseGraphRequest;

4. end;

Voilà, je crois que vous en savez assez pour démarrer avec l'unité WinGraph.

III - Tableau des meilleurs scores

Le code de l'unité HIGHSCORES.pas est emprunté pour l'essentiel à un jeu de serpent écrit par l'auteur de l'excellente bibliothèque Pulsar2D.

Les meilleurs scores seront contenus dans un tableau d'enregistrements.

1. { HIGHSCORES.pas }

3. type

4. TScore = record

5. vScore: integer;

6. vName, vDate: ansistring;

7. end;

9. THighScores = array[1..10] of TScore;

Les meilleurs scores sont sauvegardés dans un fichier texte.

1. procedure SaveHighScores(const aScores: THighScores);

2. var

3. vFile: text;

4. vIndex: integer;

5. begin

6. Assign(vFile, FILENAME);

7. Rewrite(vFile);

8. for vIndex := Low(THighScores) to High(THighScores) do

9. begin

10. WriteLn(vFile, aScores[vIndex].vScore);

11. WriteLn(vFile, aScores[vIndex].vName);

12. WriteLn(vFile, aScores[vIndex].vDate);

13. end;

14. Close(vFile);

15. end;

Au démarrage de l'application, les données seront lues dans le même ordre où on les avait écrites.

1. procedure LoadHighScores(var aScores: THighScores);

2. var

3. vFile: text;

4. vIndex: integer;

5. begin

6. InitHighScores(aScores); // Initialisation du tableau avec les valeurs par défaut.

8. if not FileExists(FILENAME) then

9. SaveHighScores(aScores); // Création du fichier de données s'il n'existe pas.

11. // Lecture du fichier

12. Assign(vFile, FILENAME);

13. Reset(vFile);

14. for vIndex := Low(THighScores) to High(THighScores) do

15. begin

16. ReadLn(vFile, aScores[vIndex].vScore);

17. ReadLn(vFile, aScores[vIndex].vName);

18. ReadLn(vFile, aScores[vIndex].vDate);

19. end;

20. Close(vFile);

21. end;

La procédure pour l'affichage des meilleurs scores se trouve dans l'unité Graphics.

1. { GRAPHICS.pas }

3. procedure DisplayHighScores(const aHighScores: THighScores);

4. const

5. cEmptyLine = '. . . . . . . . . . . .';

6. var

7. vIndex: integer;

8. begin

9. SetDrawingContext(areaLabel1);

10. DrawLabel(areaLabel1, HIGH_SCORES);

11. SetDrawingContext(areaMain, TRUE);

12. SetTextStyle(vFont or BoldFont, HorizDir, SIZE4);

13. for vIndex := Low(THighScores) to High(THighScores) do

14. begin

15. OutTextXY( 10, vIndex * 16, IfThen(aHighScores[vIndex].vName <> '', aHighScores[vIndex].vName, cEmptyLine));

16. OutTextXY( 95, vIndex * 16, Format('%0.4d', [aHighScores[vIndex].vScore]));

17. OutTextXY(130, vIndex * 16, aHighScores[vIndex].vDate);

18. end;

19. UpdateGraph(UpdateNow);

20. end;

Voici à quoi ressemble la page des meilleurs scores.

Si vous avez cinq minutes, j'ai encore à vous parler de la façon de compiler le programme et de fabriquer une icône personnalisée à partir d'une image au format PNG.

IV - Compilation

IV-A - Compilation du fichier de ressources

L'icône de l'application se trouve dans le fichier SNAKE.ico. (Dans la dernière partie de l'article, j'expliquerai comment j'ai fabriqué ce fichier.)

Pour lier cette icône à notre application, nous avons besoin d'un fichier nommé SNAKE.rc dont voici le contenu :

1. GrIcon ICON "icon\\snake.ico"

L'identificateur GrIcon est obligatoire, à moins de modifier la ligne correspondante dans l'unité WinGraph.

Vous aurez noté le dédoublement de la barre oblique dans le chemin du fichier.

Le fichier RC est compilé en fichier RES au moyen de l'outil WINDRES.exe livré avec Free Pascal. La ligne de commande est la suivante :

1. windres.exe -i snake.rc -o snake.res

IV-B - Compilation du programme

La compilation du programme ne pose pas de problème particulier, si ce n'est qu'il faut indiquer au compilateur le chemin des unités WinGraph et WinCrt. Vous pouvez pour cela créer un projet dans votre EDI préféré, ou utiliser directement une ligne de commande.

Au cas où cela vous intéresse, voici comment compiler le programme par une ligne de commande, avec un fichier de configuration. Ce fichier — appelons-le BUILD.cfg — contient les options de compilations :

1. # Mode Delphi

2. -Mdelphi

3. # Chemin des unités

4. -Fulibraries

5. # Chemin pour les unités compilées

6. -FUlibraries\bin

Le compilateur est appelé de la façon suivante :

1. fpc.exe snake.pas @build.cfg

IV-C - Astuce pour un programme en plusieurs langues

Pour rendre notre jeu disponible dans plusieurs langues, je vous propose une solution qui consiste à modifier automatiquement le code source du programme à chaque compilation, en fonction de la langue choisie.

La modification du code source se fait par la copie d'un fichier. Par exemple, pour compiler la version française du jeu, le fichier FRENCH.inc sera renommé en LANGUAGE.inc faisant partie du projet.

Tout cela pourra être fait par le fichier de commandes suivant (que nous appellerons si vous le voulez bien BUILD.cmd) :

1. set lang=%1

2. if [%lang%] == [] (

3. set lang=english

4. )

6. copy languages\%lang%.inc language.inc

8. fpc.exe snake.pas @build.cfg

Ce fichier de commandes sera appelé depuis l'invite de commande, par exemple de la façon suivante :

1. build french

Étonnant, non ?

V - Fabrication d'une icône à partir d'une image au format PNG

Pour finir, je veux bien partager avec vous la méthode que j'ai suivie pour fabriquer l'icône du jeu à partir d'une image au format PNG.

Cette image, je l'avais obtenue à partir de l'image suivante, provenant d'un tutoriel sur la réalisation d'un jeu de serpent en HTML5 :

Une fois les morceaux du serpent recollés (au moyen d'un petit programme Lazarus écrit spécialement à cette fin), j'utilise le logiciel ImageMagick pour convertir le fichierSNAKE.png en SNAKE.ico. La ligne de commande est la suivante :

1. convert snake.png -define icon:auto-resize=64,48,32,16 snake.ico

Nous avons vu plus haut comment utiliser le fichier SNAKE.ico.

VI - Conclusion

Je ne vous ai pas parlé des sons que j'ai ajoutés à mon jeu. Je vous laisse consulter éventuellement l'unité Sound. L'unité Sound fait appel à la bibliothèque BASS.

Les sons que j'ai utilisés proviennent de cette collection gratuite.

J'espère que ce tutoriel vous aura été utile. Je remercie pour leur relecture Alcatîz, tourlourou, gvasseur58, Andnotor et ClaudeLELOUP.

Vous pouvez télécharger le code source complet du jeu présenté dans cet article.