Partie 14: Interface du jeu - Partie 2

Téléchargements

Code source
Exécutable de l'éditeur de niveaux - exactement le même que la partie 12 (Windows 32bits)
Exécutable du jeu (Windows 32bits)

Avant d'essayer de compiler le code, allez dans l'onglet "Projets" dans le menu de gauche, séléctionnez l'onglet "Run" pour votre kit,
et mettez dans "Working directory" le chemin de "editor\data" pour l'éditeur ou "game\data" pour le jeu.

Nettoyage du code

J'ai déplacé le code pour dessiner les décors qu'on a utilisé la dernière fois dans "walls.cpp".
De cette façon le code de "game.cpp" est plus propre et ça sera plus facile pour ajouter différents types de murs.

		void CGame::drawWall(QImage* image, CVec2 mapPos, CVec2 tablePos, EWallSide side, bool flip)
		{
			CTile*  tile = map.getTile(mapPos);

			if (tile != NULL)
			{
				EWallSide playerSide = player.getWallSide(side);

				if (tile->mWalls[playerSide].getType() != eWallNothing)
				{
					const char* fileName = walls.getFilename(tablePos, side, flip);

					if (fileName[0] != 0)
					{
						// dessine le mur
						QImage  wallGfx = fileCache.getImage(fileName);
						CVec2 gfxPos = walls.getGfxPos(tablePos, side, flip);

						graph2D.drawImage(image, gfxPos, wallGfx, 0, flip);

						// dessine les décors
						CWall*  wall = &tile->mWalls[playerSide];

						if (wall->getType() == eWallSimple)
						{
							int ornate = ((CParamOrnate*)wall->mParams[0])->mValue;
							walls.drawOrnate(image, tablePos, side, ornate);
						}
					}
				}
			}
		}
				
Si vous regardez la fonction drawOrnate() vous verrez que j'ai aussi déplacé la partie qui assombrit l'image dans une autre fonction
dans "cgraphics2d.cpp". J'essaierai de garder toutes ces fonctions graphiques de bas niveau dans ce fichier, ça sera plus facile pour
éventuellement porter ce jeu dans un autre moteur graphique.
C'est toujours une bonne idée de penser à la portabilité pendant le développement.

Atlasing

Les graphs des personnages en haut de l'écran avaient l'air plutôt vides dans la partie 11.
Alors essayons d'afficher les têtes des champions.
Dans le jeu d'origine, elles étaient toutes stockées dans une seule image.


A l'époque du jeu d'origine, on appelait ça une planche de sprites.
A l'ère de la 3D, on parlerait plutôt d'atlasing de texture.
Alors j'ai écrit une fonction appelée drawImageAtlas() pour copier seulement une partie de l'image source à l'écran, et je l'ai utilisée
pour dessiner la tête dans drawChampion().
En même temps, j'ai aussi dessiné les mains quand l'inventaire du champion n'est pas ouvert. Elles apparaissent dans une autre planche de
sprites avec quelques objets.



		void    CInterface::drawChampion(QImage* image, int num)
		{
			if (isChampionEmpty(num) == false)
			{
				[...]

				if (isChampionDead(num) == true)
				{
					[...]
				}
				else
				{
					QImage  champBg = fileCache.getImage("gfx/interface/ChampInfo.png");
					CVec2   pos(69 * num, 0);
					graph2D.drawImage(image, pos, champBg);

					if (isInInventory() == true && currentChampion == num)
					{
						// dessine un rectangle dans le fond
						CVec2   pos2(69 * num, 0);
						CRect   rect2(pos2, CVec2(pos2.x + 42, 6));
						graph2D.rect(image, rect2, QColor(68, 68, 68), true);

						CVec2   pos(69 * num + 7, 0);
						CRect   rect(pos, CVec2(pos.x + CHAMP_PORTRAIT_WIDTH - 1, pos.y + CHAMP_PORTRAIT_HEIGHT - 1));
						graph2D.rect(image, rect, QColor(102, 102, 102), true);

						// dessine la tête
						CCharacter* c = &game.characters[num];
						rect = getChampionPortraitRect(c->picture);
						QImage  portraits = fileCache.getImage("gfx/interface/ChampPortraits.png");
						graph2D.drawImageAtlas(image, pos, portraits, rect);

						[...]
					}
					else
					{
						// dessine les rectangles
						CRect   rect(CVec2(69 * num + 3, 9),
									 CVec2(69 * num + 20, 26));
						graph2D.rect(image, rect, QColor(102, 102, 102), false);

						CRect   rect2(CVec2(69 * num + 23, 9),
									 CVec2(69 * num + 40, 26));
						graph2D.rect(image, rect2, QColor(102, 102, 102), false);

						// dessine les mains
						QImage  bodyParts = fileCache.getImage("gfx/interface/Items6.png");

						rect = getBodyPartRect(CCharacter::eBodyPartLeftHand);
						CVec2   pos(69 * num + 4, 10);
						graph2D.drawImageAtlas(image, pos, bodyParts, rect);

						rect = getBodyPartRect(CCharacter::eBodyPartRightHand);
						CVec2   pos2(69 * num + 24, 10);
						graph2D.drawImageAtlas(image, pos2, bodyParts, rect);

						// écrit le nom
						CCharacter* c = &game.characters[num];
						CVec2   pos3(69 * num + 1, 1);
						drawText(image, pos3, eFontStandard, c->name.c_str(), QColor(173, 173, 173));

						[...]
					}

					// barres
					drawBar(image, num, CVec2(69 * num + 46, 2), CCharacter::eStatHealth);
					drawBar(image, num, CVec2(69 * num + 53, 2), CCharacter::eStatStamina);
					drawBar(image, num, CVec2(69 * num + 60, 2), CCharacter::eStatMana);
				}
			}
		}
				

Les textes

Maintenant essayons d'écrire les noms des champions au-dessus de leurs mains.
Le jeu d'origine utilisait 3 polices, une pour les textes sur les murs, une pour les parchemins et la dernière pour tous
les autres textes du jeu.

Tous les caractères d'une police apparaissent sur la même image. Alors on va encore utiliser drawImageAtlas().
La seule modification que j'ai faite pour ça c'est d'ajouter un paramètre pour choisir la couleur de la police.

Mais la partie la plus difficile est de convertir un texte lisible en positions dans la planche de sprites de la police.
C'est pourquoi la plus grande partie de la fonction d'écriture de texte sont 3 grosses tables utilisées pour convertir
le code ASCII de chaque caractère en un numéro de sprite.

		void   CInterface::drawText(QImage* image, CVec2 pos, EFonts font, const char* text, QColor color)
		{
			// Table des numéros de sprites pour chaque caractère ASCII du caractère 32 au caractère 120
			// Les lettres en minuscule sont utilisées pour les sorts
			//  !"#$%&'()*+,-./
			// 0123456789:;<=>?
			// @ABCDEFGHIJKLMNO
			// PQRSTUVWXYZ[\]^_
			// `abcdefghijklmno
			// pqrstuvwx

			// eFontStandard
			static const int codesStandard[] =
			{
				//      !     "     #     $     %     &     '     (     )     *     +     ,     -     .     /
				0x00, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, 0x2F,
				[...]
			};

			// eFontScroll
			static const int codesScroll[] =
			{
				//      !     "     #     $     %     &     '     (     )     *     +     ,     -     .     /
				0x00, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x1D, 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, 0x2F,
				[...]
			};

			// eFontWalls
			static const int codesWalls[] =
			{
				//      !     "     #     $     %     &     '     (     )     *     +     ,     -     .     /
				0x1A, 0x1A, 0x1A, 0x1A, 0x1A, 0x1A, 0x1A, 0x21, 0x1A, 0x1A, 0x1A, 0x1A, 0x1A, 0x1A, 0x1B, 0x1A,
				[...]
			};

			const int* codes;
			int offset;
			QImage  file;

			if (font == eFontStandard)
			{
				codes = codesStandard;
				file = fileCache.getImage("gfx/interface/Font.png");
				offset = -2;
				pos.x -= 3;
			}
			else if (font == eFontScroll)
			{
				codes = codesScroll;
				file = fileCache.getImage("gfx/interface/Font.png");
				offset = -2;
				pos.x -= 3;
			}
			else
			{
				codes = codesWalls;
				file = fileCache.getImage("gfx/3DView/WallFont.png");
				offset = 0;
			}

			int i = 0;

			while (text[i] != 0)
			{
				int c = codes[text[i] - 32];

				CRect   rect(CVec2(c * 8, 0),
							 CVec2(c * 8 + 7, file.height() - 1));
				CVec2   cPos(pos.x + i * (8 + offset), pos.y);

				graph2D.drawImageAtlas(image, cPos, file, rect, (font != eFontWalls ? color : QColor(0, 0, 0, 0)));
				i++;
			}
		}
				
Si on met tout ça ensemble on obtient finalement quelque chose comme ça:


La souris

Normalement la tête d'un champion apparait seulement quand on regarde son inventaire.
Dans le jeu d'origine, pour entrer dans l'inventaire d'un personnage, il fallait cliquer sur ses barres de statistiques.
Alors maintenant on va commencer à gérer la souris.

Dans "MainForm.ui.qml" il y a une MouseArea attachée à notre image de l'écran:

		Rectangle {
			width: 320
			height: 200
			color: "black"

			property alias image1: image1
			property alias mouseArea1: mouseArea1

			Image {
				id: image1
				anchors.fill: parent
				fillMode: Image.PreserveAspectFit
				cache: false
				clip: true
				smooth: false
				horizontalAlignment: Image.AlignHCenter
				verticalAlignment: Image.AlignVCenter
				source: "image://myimageprovider/0"

				MouseArea {
					id: mouseArea1
					anchors.fill: parent
					hoverEnabled: true
					acceptedButtons: Qt.LeftButton | Qt.RightButton
				}
			}
		}
				
Comme on le fait dans l'éditeur, dans "main.qml" on va intercepter les mouvements et les clics de la souris pour les
envoyer au fichier bridge.cpp:

		mouseArea1.onMouseXChanged: bridge.setMouse(mouseArea1.mouseX, mouseArea1.mouseY, mouseArea1.width, mouseArea1.height);
		mouseArea1.onMouseYChanged: bridge.setMouse(mouseArea1.mouseX, mouseArea1.mouseY, mouseArea1.width, mouseArea1.height);
		mouseArea1.onPressed:bridge.setMousePress(mouse.button);
		mouseArea1.onReleased: bridge.setMouseRelease(mouse.button);
				
Ensuite, on stocke ces données dans "bridge.cpp".
Pour les clics, c'est facile, on stocke seulement l'état courant du bouton (pressé ou relaché).
Mais pour la position de la souris c'est un peu plus compliqué.
Comme la MouseArea est une enfant de l'image à l'écran, elle devrait être de la même taille (320 * 200).
Mais parce que l'image est affichée avec PreserveAspectFit, à cause s'un bug de Qt, la MouseArea prend la taille de
la fenêtre entière de notre jeu. Même si l'image est plus petite et s'il y a des bandes noires autour, la MouseArea
les comprend aussi.
Donc on doit faire quelques calculs pour avoir la position de la souris par rapport à notre image:

		void    CBridge::setMouse(qint32 x, qint32 y, qint32 windowWidth, qint32 windowHeight)
		{
			CVec2   imagePos;
			CVec2   imageSize;
			float widthScale = (float)windowWidth / (float)SCREEN_WIDTH;
			float heightScale = (float)windowHeight / (float)SCREEN_HEIGHT;

			if (widthScale <= heightScale)
			{
				imageSize.x = (int32_t)windowWidth;
				imageSize.y = (int32_t)(widthScale * (float)SCREEN_HEIGHT);
				imagePos.x = 0;
				imagePos.y = (windowHeight - imageSize.y) / 2;
			}
			else
			{
				imageSize.x = (int32_t)(heightScale * (float)SCREEN_WIDTH);
				imageSize.y = (int32_t)windowHeight;
				imagePos.x = (windowWidth - imageSize.x) / 2;
				imagePos.y = 0;
			}

			mMousePos = CVec2(x, y) - imagePos;
			mMousePos.x = (mMousePos.x * SCREEN_WIDTH) / imageSize.x;
			mMousePos.y = (mMousePos.y * SCREEN_HEIGHT) / imageSize.y;
		}

		void    CBridge::setMousePress(qint32 button)
		{
			if (button == Qt::LeftButton)
				mMouseButton = true;
		}

		void    CBridge::setMouseRelease(qint32 button)
		{
			if (button == Qt::LeftButton)
				mMouseButton = false;
		}
				

La boucle de jeu

Comme on commence à gérer plus d'entrées qui interagissent aves les graphismes du jeu, c'est le moment de réorganiser le code.
Maintenant l'"image provider" qui est appelé 60 fois par seconde par le QML n'appellera pas directement une fonction pour
dessiner les graphismes, mais une boucle de jeu:

		QImage CGame::mainLoop()
		{
			QImage image(SCREEN_WIDTH,
			             SCREEN_HEIGHT,
			             QImage::Format_RGBA8888);

			mouse.clearAreas();

			// efface l'écran
			image.fill(QColor(0, 0, 0));

			// affichage
			displayMainView(&image);
			interface.display(&image);

			// logique du jeu
			mouse.update();
			SMouseArea* clickedArea = mouse.clickedArea();

			interface.update(clickedArea);

			return image;
		}
				
A l'époque de l'Amiga et de l'Atari, tous les jeux avaient cette architecture: une fonction principale qui était apppelée
50 ou 60 fois par seconde, et qui gérait l'affichage, les entrées et la logique du jeu.

Il est important dans un jeu de séparer la partie "graphique" et la partie "logique" de chaque composant car ça évite
des bugs comme des images qui clignotent, ou des images qui sont dessinées 2 fois pendant la même frame.

C'est aussi important de synchroniser les graphismes avec la lecture d'une animation ou la gestion des entrées.
Les graphismes vont changer 60 fois par seconde. Donc il n'y a pas besoin de gérer les entrées plus souvent.
Et quand on les gère, on a besoin d'utiliser la valeur qu'ils ont exactement à ce moment. pas 1/100ème de seconde
avant ou après.

Pour le moment le clavier est encore géré de manière asynchrone. Mais je changerai bientôt ça pour le mettre dans la boucle
principale, parce qu'il devra être synchronisé avec d'autres éléments.

Comme vous pouvez le voir il y a plusieurs fonctions associées à la souris. Je vais les expliquer maintenant.

La souris - partie 2

J'ai ajouté un nouveau fichier source "mouse.cpp". Regardons son ".h":

		#ifndef MOUSE_H
		#define MOUSE_H

		#include "../common/sources/system/cmaths.h"
		#include <vector>

		enum EMouseAreaTypes
		{
			eMouseArea_Inventory = 0,
			eMouseArea_3DView
		};

		struct SMouseArea
		{
			EMouseAreaTypes type;
			CRect   rect;
			void*   param1;
			void*   param2;
		};

		class CMouse
		{
		public:
			CMouse();

			void        update();
			void        clearAreas();
			void        addArea(EMouseAreaTypes type, CRect rect, void* param1 = NULL, void* param2 = NULL);
			SMouseArea* clickedArea();

			CVec2   mPos;
			bool    mButtonPressed;
			bool    mButtonPressing;
			bool    mButtonReleasing;

		private:
			std::vector<SMouseArea>   mAreaList;
		};

		extern CMouse  mouse;

		#endif // MOUSE_H
				
D'abord, parlons de la fonction update().
"bridge.cpp" nous donne l'état courant du bouton de la souris, pressé ou pas.
Maintenant, imaginons qu'on clique sur les barres d'un champion pour entrer dans sa feuille de personnage.
Quand la feuille est affichée, un autre clic sur les barres nous renvoie dans le jeu.
Disons que l'on presse le bouton pendant 1 seconde. Si on utilise seulement le bit qui est dans "bridge.cpp",
il restera à "true" pendant 1 seconde, et les images vont commuter 60 fois entre la feuille et le jeu.

Ce qu'il nous faut c'est un bit qui est mis à "true" juste pendant la frame ou on presse le bouton, et qui est remis à
"false" si on continue d'appuyer sur le bouton pendant la frame suivante.
C'est le travail de la fonction update(). A chaque frame, elle met à jour les variables mButtonPressed, mButtonPressing
et mButtonReleasing.
mButtonPressed est simplement une copie du bit de "bridge.cpp".
mButtonPressing est mise à "true" seulement pendant la frame où on presse le bouton.
Et mButtonReleasing est mise à "true" seulement pendant la frame ou on le relache.

update() copie aussi la position de la souris de "bridge.cpp" dans la variable mPos.
Travailler avec des copies comme ça est une sécurité. On est sur qu'elles ne changeront pas pendant qu'on les gère,
même si les variables de "bridge.cpp" changent.

Maintenant parlons des autres fonctions.
Tout le système de la souris marche avec une liste de "SMouseArea".
Chaque "mouse area" contient un rectangle à l'écran avec un type pour l'identifier et quelques paramètres qui peuvent être
ajoutés pour des cas particuliers.

Au début de chaque frame, cette liste est vidée avec la fonction clearAreas().
Pendant qu'on dessine les graphismes, si on veut pouvoir cliquer sur l'un d'entre eux, on ajoute son rectangle à la liste
avec addArea().
Et une fois que tout est affiché, on teste si le bouton de la souris est pressé et si elle est à l'intérieur d'un des rectangles
avec la fonction clickedArea().

L'atuce c'est que clickArea() parcourt les rectangles dans l'ordre inverse de celui où ils ont été ajoutés.
Parce que si par exemple on affiche 2 objets qui se superposent, on va dessiner d'abord le plus loin puis le plus proche.
Mais quand on clique dessus, on veut que ce soit le plus proche qui soit touché d'abord.

Et finalement, si vous regardez la fonction CInterface::update(), vous verrez que c'est assez facile de gérer l'action
associée à un rectangle donné:

		void   CInterface::update(SMouseArea* clickedArea)
		{
			if (clickedArea != NULL)
			{
				if (mouse.mButtonPressing == true)
				{
					switch (clickedArea->type)
					{
						case eMouseArea_Inventory:
							mainState = eMainInventory;
							currentChampion = (int)clickedArea->param1;
						break;

						case eMouseArea_3DView:
							mainState = eMainGame;
						break;

						default:
						break;
					}
				}
			}
		}
				

Encore un peu plus...

Maintenant qu'on peut entrer dans l'inventaire d'un champion, ca ne serait pas très beau de ne voir qu'un écran noir.
Alors j'ai rapidement ajouté le début d'une feuille de personnage avec les parties de son corps qui viennent de la planche
de sprites qu'on a vue au début.