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 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
{ TYPSHRDH.inc }
TPoint = record
x : Longint
;
y : Longint
;
public
constructor
Create(ax,ay:Longint
); overload
;
constructor
Create(apt :TPoint); overload
;
class
function
Zero: TPoint; static
; inline
;
function
Add(const
apt: TPoint): TPoint;
function
Distance(const
apt: TPoint) : ValReal;
function
IsZero : Boolean
;
function
Subtract(const
apt : TPoint): TPoint;
procedure
SetLocation(const
apt :TPoint);
procedure
SetLocation(ax,ay : Longint
);
procedure
Offset(const
apt :TPoint);
procedure
Offset(dx,dy : Longint
);
class
function
PointInCircle(const
apt, acenter: TPoint; const
aradius: Integer
): Boolean
; static
; inline
;
class
operator = (const
apt1, apt2 : TPoint) : Boolean
;
class
operator <> (const
apt1, apt2 : TPoint): Boolean
;
class
operator + (const
apt1, apt2 : TPoint): TPoint;
class
operator - (const
apt1, apt2 : TPoint): TPoint;
class
operator := (const
aspt : TSmallPoint) : TPoint;
class
operator Explicit (Const
apt : TPoint) : TSmallPoint;
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.
2.
3.
{ SNAKEGAME.pas }
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 :
2.
3.
4.
const
cInitialLength = 2
;
begin
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 :
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 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
const
DIRECTIONS: array
[0
..3
] of
TPoint = (
(x: 0
; y: +1
),
(x: +1
; y: 0
),
(x: 0
; y: -1
),
(x: -1
; y: 0
)
);
var
vDirection: TPoint;
vDirectionIndex: integer
;
begin
vDirectionIndex := 1
;
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 :
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 := :
vSnake[1
] := vSnake[0
].Subtract(vDirection);
Ou comme avec un enregistrement classique :
2.
snake[1
].x := snake[0
].x - direction.x;
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 :
vSnake[1
] := TPoint.Create(vSnake[0
].Subtract(vDirection));
Ce qui est la même chose que :
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 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
procedure
TSnakeGame.MoveSnake;
begin
{ Déplacer le corps du serpent en fonction de la position antérieure de la tête. }
Move(vSnake[0
], vSnake[1
], High(vSnake) * SizeOf(TPoint));
{ Déplacer la tête en fonction de la direction courante. }
vSnake[0
].Offset(vDirection);
{ Si la tête est sortie de la surface de jeu, la téléporter de l'autre côté. Utile seulement si la
téléportation est autorisée, autrement dit si la fonction Collision a été appelée avec la valeur
FALSE, c'est-à-dire en mode "sans murs". }
if
vSnake[0
].x > XMAX then
vSnake[0
].x := XMIN;
if
vSnake[0
].x < XMIN then
vSnake[0
].x := XMAX;
if
vSnake[0
].y > YMAX then
vSnake[0
].y := YMIN;
if
vSnake[0
].y < YMIN then
vSnake[0
].y := YMAX;
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 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
function
TSnakeGame.IsSnake(const
aPoint: TPoint): boolean
;
var
vTail, vSnakePart: integer
;
begin
vTail := High(vSnake);
vSnakePart := 1
;
result := FALSE
;
while
(vSnakePart <= vTail) and
not
result do
begin
//result := PointsEqual(aPoint, vSnake[vSnakePart]);
result := aPoint = vSnake[vSnakePart];
Inc(vSnakePart);
end
;
end
;
function
TSnakeGame.IsApple(const
aPoint: TPoint): boolean
;
begin
//result := PointsEqual(aPoint, vApple);
result := aPoint = vApple;
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).
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
{ GRAPHICS.pas }
procedure
OpenWindow;
var
vDriver, vMode: smallint
;
begin
SetWindowSize(vFieldHeight, 2
* vLabelHeight + vFieldHeight);
vDriver := NoPalette;
vMode := mCustom;
InitGraph(vDriver, vMode, GAME_TITLE);
vFont := InstallUserFont('Verdana'
);
UpdateGraph(UpdateOff);
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 :
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.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
type
TDrawContext = record
vTop, vBottom, // Coordonnées de la zone de dessin.
vTextSize: integer
; // Hauteur du texte.
vColor, vBkColor: longword; // Couleurs (avant-plan, arrière-plan).
constructor
Create(const
aTop, aBottom, aTextSize: integer
; const
aColor, aBkColor: longword);
end
;
constructor
TDrawContext.Create(const
aTop, aBottom, aTextSize: integer
; const
aColor, aBkColor: longword);
begin
vTop := aTop;
vBottom := aBottom;
vTextSize := aTextSize;
vColor := aColor;
vBkColor := aBkColor;
end
;
var
vContextArray: array
[TScreenArea] of
TDrawContext;
procedure
SetDrawingContext(const
aScreenArea: TScreenArea; const
aClearViewPort: boolean
);
begin
with
vContextArray[aScreenArea] do
begin
SetViewPort(0
, vTop, vFieldHeight - 1
, vBottom, ClipOff); // Définition de la zone de dessin.
SetColor(vColor); // Choix de la couleur d'avant-plan.
SetBkColor(vBkColor); // Choix de la couleur d'arrière-plan.
SetTextStyle(vFont or
BoldFont, HorizDir, vTextSize); // Choix de la hauteur du texte.
if
aClearViewPort then
ClearViewPort; // Effacement de la zone de dessin.
end
;
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 !
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
procedure
DrawPoint(const
aSquare: TPoint; const
aColor: TPointColor; const
aUpdateScreen: boolean
);
var
x, y: integer
;
vColor, vFillColor: longword;
begin
case
aColor of
colorSnake:
begin
vColor := LimeGreen;
vFillColor := Green;
end
;
colorApple:
begin
vColor := Red;
vFillColor := DarkRed;
end
;
colorBackground:
begin
vColor := Black;
vFillColor := Black;
end
;
end
;
SetColor(vColor); // Couleur des bords du carré.
SetFillStyle(SolidFill, vFillColor); // Couleur de remplissage du carré.
x := vSquareWidth * (aSquare.x - 1
);
y := vFieldHeight - (vSquareWidth * aSquare.y);
FillRect(x + 1
, y + 1
, x + vSquareWidth - 1
, y + vSquareWidth - 1
); // Dessin du carré.
if
aUpdateScreen then
UpdateGraph(UpdateNow); // Rafraîchissement de l'écran.
end
;
II-D. Fermeture de la fenêtre▲
Pour fermer la fenêtre, nous utiliserons la procédure CloseGraph :
2.
3.
4.
procedure
CloseWindow;
begin
CloseGraph;
end
;
Pour savoir si l'utilisateur cherche à fermer la fenêtre, nous appellerons la fonction CloseGraphRequest :
2.
3.
4.
function
CloseRequest: boolean
;
begin
result := CloseGraphRequest;
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.
2.
3.
4.
5.
6.
7.
8.
9.
{ HIGHSCORES.pas }
type
TScore = record
vScore: integer
;
vName, vDate: ansistring;
end
;
THighScores = array
[1
..10
] of
TScore;
Les meilleurs scores sont sauvegardés dans un fichier texte.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
procedure
SaveHighScores(const
aScores: THighScores);
var
vFile: text;
vIndex: integer
;
begin
Assign(vFile, FILENAME);
Rewrite(vFile);
for
vIndex := Low(THighScores) to
High(THighScores) do
begin
WriteLn(vFile, aScores[vIndex].vScore);
WriteLn(vFile, aScores[vIndex].vName);
WriteLn(vFile, aScores[vIndex].vDate);
end
;
Close(vFile);
end
;
Au démarrage de l'application, les données seront lues dans le même ordre où on les avait écrites.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
procedure
LoadHighScores(var
aScores: THighScores);
var
vFile: text;
vIndex: integer
;
begin
InitHighScores(aScores); // Initialisation du tableau avec les valeurs par défaut.
if
not
FileExists(FILENAME) then
SaveHighScores(aScores); // Création du fichier de données s'il n'existe pas.
// Lecture du fichier
Assign(vFile, FILENAME);
Reset(vFile);
for
vIndex := Low(THighScores) to
High(THighScores) do
begin
ReadLn(vFile, aScores[vIndex].vScore);
ReadLn(vFile, aScores[vIndex].vName);
ReadLn(vFile, aScores[vIndex].vDate);
end
;
Close(vFile);
end
;
La procédure pour l'affichage des meilleurs scores se trouve dans l'unité Graphics.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
{ GRAPHICS.pas }
procedure
DisplayHighScores(const
aHighScores: THighScores);
const
cEmptyLine = '. . . . . . . . . . . .'
;
var
vIndex: integer
;
begin
SetDrawingContext(areaLabel1);
DrawLabel(areaLabel1, HIGH_SCORES);
SetDrawingContext(areaMain, TRUE
);
SetTextStyle(vFont or
BoldFont, HorizDir, SIZE4);
for
vIndex := Low(THighScores) to
High(THighScores) do
begin
OutTextXY( 10
, vIndex * 16
, IfThen(aHighScores[vIndex].vName <> ''
, aHighScores[vIndex].vName, cEmptyLine));
OutTextXY( 95
, vIndex * 16
, Format('%0.4d'
, [aHighScores[vIndex].vScore]));
OutTextXY(130
, vIndex * 16
, aHighScores[vIndex].vDate);
end
;
UpdateGraph(UpdateNow);
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 :
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 :
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 :
2.
3.
4.
5.
6.
# Mode Delphi
-Mdelphi
# Chemin des unités
-Fulibraries
# Chemin pour les unités compilées
-FUlibraries\bin
Le compilateur est appelé de la façon suivante :
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) :
2.
3.
4.
5.
6.
7.
8.
set
lang=
%1
if
[%lang%
] ==
[] (
set
lang=
english
)
copy
languages\%lang%
.inc language.inc
fpc.exe snake.pas @
build.cfg
Ce fichier de commandes sera appelé depuis l'invite de commande, par exemple de la façon suivante :
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 :
con
vert 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.