Partie 19: Plaques de pression et scripts

Téléchargements

Code source
Exécutable de l'éditeur de niveaux (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.

Interagir avec la map

Une partie importante du jeu est la possibilité d'agir sur la map. La dernière fois on a vu la façon la plus facile
de le faire: ouvrir une porte en pressant un bouton sur elle.
Mais le jeu utilise aussi des plaque de pression sur le sol, des boutons, des leviers, des serrures, et d'autres
systèmes pour agir sur un élément qui peut parfois être distant.
Alors on doit trouver un moyen de créér un "lien" entre ces éléments.

Le jeu d'origine utilisait des "actuators" (une sorte d'interrrupteur invisible qui agissait sur un élément
particulier).
Le problème c'est qu'il y avait beaucoup d'actuators différents, en fonction de la façon dont ils étaient activés
(par le joueur, un monstre ou un objet) et de l'élément sur lequel ils agissaient (une case, un mur, une porte,
etc.). Et ces actuators pouvaient avoir beaucoup de paramètres pour modifier la façon dont ils agissaient sur
leur élément cible.

Mais on va essayer de mettre en place une technique plus simple.
Dans le premier niveau il y a une plaque de pression qui ouvre une porte seulement si on a ajouté au moins un
champion à notre équipe.
Alors définissons précisément ce qu'on va devoir faire.


La plaque de pression va agir sur sa cible seulement dans 2 cas: soit quand elle est pressée (par ex. quand notre
groupe marche dessus) ou quand elle est relachée.
Les autres types de "dispositifs" qu'on rencontrera plus tard ne réagiront aussi que dans quelques cas.
Pour un bouton ou un levier ça sera quand il sera mis "on", ou "off".
Pour une serrure, elle réagira quand on mettra la bonne clé dessus.
Peut-être que l'on rencontrera plus tard des éléments qui réagissent quand un "minuteur" est écoulé. Mais ce n'est
pas le cas pour l'instant, alors gardons les choses simples.

Dans le cas de notre plaque de pression, il pourait y avoir différentes façons de la "presser".
Ca peut-être quand nos personnages marchent dessus, comme celle du premier niveau.
D'autres plaques pourraient être activées quand un monstre marche dessus.
Et d'autres pourraient réagir quand on pose un objet dessus.
Ou ça pourrait être une combinaisons de plusieurs de ces méthodes.
Pour définir tous ces cas, on va simplement ajouter des paramètres booléens à la case où est la plaque dans
l'éditeur.

Maintenant quelles sont les cibles qui peuvent être affectées par ces boutons et ces plaques?
Pour le moment, dans le jeu on peut seulement agir sur un mur, une case ou une porte (qui est seulement un type
particulier de case).
A l'avenir on devra probablement agir sur d'autres éléments, mais la plupart du temps on retombera sur ceux-ci.
Par exemple, un "trou" sera un type spécial de case. Un téléporteur aussi, comme il se trouve sur une case
spécifique...

Ensuite, qu'est-ce qu'on va modifier sur ces éléments cibles ?
On va soit changer le type de l'élément. Par ex. on peut faire disparaître un mur.
Ou on peut changer la valeur d'un de ses paramètres. Ce qui sera simple parce qu'on peut les retrouver par leur nom.
En fait, dans certains cas on devra changer plusieurs paramètres, et peut-être agir sur plusieurs éléments avec la
même action.

Donc comme il peut y avoir beaucoup de possibilités d'actions, et que l'on veut pouvoir les modifier facilement, la
façon la plus simple de les définir est d'écrire de simple fichiers scripts.
On verra quelle forme ils prendront plus tard. Pour l'instant, commençons par mettre en place notre plaque de
pression dans l'éditeur.

La plaque de pression

La plaque de pression va ressembler à ca dans l'éditeur:


Le type définit l'image qu'on veut. Ca peut être "Aucune" (pour une plaque invisible) "Carree", "Ronde" ou "Petite",
comme les graphismes que l'on a vus dans les décors de sol.
Les cases à cocher suivantes définissent si la plaque peut être activée par nos champions, les monstres ou les
objets.
joueUnSon sera utilisé plus tard, quand on ajoutera les sons au jeu. Car dans certains cas on pourra avoir besoin de
plaques qui ne produisent pas de son.
Puis nous définissons les scripts qui seront appelés quand on presse ou qu'on relache la plaque. C'est simplement
le nom d'un fichier texte.
Les boutons à coté de ces lignes ouvrent un sélecteur de fichiers pour choisir le script qu'on veut.

Donc voilà à quoi ça ressemble dans "tiles.xml":

		<tile name="Plaque de pression">
			<image>PressurePlate.png</image>
			<param type="enum" values="Aucune;Carree;Ronde;Petite">Type</param>
			<param type="bool">parChampions</param>
			<param type="bool">parMonstres</param>
			<param type="bool">parObjets</param>
			<param type="bool">joueUnSon</param>
			<param type="script">siPressee</param>
			<param type="script">siRelachee</param>
		</tile>
				
Vous pouvez voir que j'ai rajouté deux nouveaux types de paramètres.
"script" est seulement une chaine de caractères qui contient le nom du fichier texte.

"enum" est une "combo box" personnalisée. Les différents types de plaques de pression sont seulement un sous-
ensemble des décors de sol.
Comme on a déjà les graphismes dans "floor_ornates.xml", on n'a pas besoin de créer une nouvelle base de données.
De plus, l'éditeur lit déjà beaucoup de bases de données, et plus on en ajoute, moins on ça sera flexible pour
l'utiliser avec d'autres jeux.
Donc on a juste besoin d'un type personnalisé pour convertir un nom en un nombre entier.

Les valeurs que cet enum peut prendre sont stockées dans mTilesParams (la liste qui contient les noms et les types
de chaque paramètre pour chaque type de case).

		struct CParamType
		{
			EParamType  mType;
			QString     mName;
			QStringList mValues;
		};

		[...]

		std::vector<std::vector<CParamType>>  mTilesParams; // liste des paramètres pour chaque type de case
				
Dans le jeu, la plaque de pression est simplement dessinée dans CGame::displayMainView() au même endroit que les
autres décors de sol:

		if (tile->getType() == eTileGround)
		{
			//------------------------------------------------------------------------------
			// case de sol
			int ornate = tile->getOrnateParam("Ornate");
			tiles.drawFloorOrnate(image, tablePos, ornate);
		}
		else if (tile->getType() == eTilePressPlate)
		{
			//------------------------------------------------------------------------------
			// plaque de pression
			int ornate = tile->getEnumParam("Type");

			if (ornate != 0)
				tiles.drawFloorOrnate(image, tablePos, ornate + 6);
		}
				
Les graphismes des décors de sol sont dans les même ordre que celui qu'on a choisi pour notre "combo
box", donc on a juste à ajouter "6" pour obtenir le graphisme de la plaque carrée.

Le script

Enfin jetons un oeil au script. Je vais juste écrire le code necessaire pour ouvrir la porte avec la plaque de
pression dans le premier niveau.
Alors voici le script:

		Target Tile 5 9
		SetBool estOuverte true
				
La première ligne définit la cible: la case aux coordonnées (5, 9). Pour un mur il faudrait juste qu'on ajoute un
paramètre de plus pour définir son coté.
La seconde ligne change un paramètre booléen dans la cible qu'on a défini (dans notre cas, le paramètre "estOuverte"
est mis à "true").
Comme on l'a vu dans la dernière partie, positionner ce paramètre est la seule chose necessaire pour ouvrir la
porte, comme les animations se basent uniquement sur cette valeur.

A l'avenir on ajoutera d'autres fonctions pour changer les valeurs des différents types de paramètres: SetInt,
SetString, etc... Avec cette méthode, vous pouvez voir que c'est facile de changer plusieurs paramètres de la même cible, ou d'agir
sur de multiples cibles, comme les autres fonctions utilisent seulement la dernière cible définie.

Maitenant regardons comment ce script est exécuté dans le code. J'ai ajouté un fichier "scripts.cpp" où l'on peut
trouver la fonction principale execute():

		void CScripts::execute(std::string fileName)
		{
			if (fileName.empty() == false)
			{
				static char cFileName[256];
				static char line[256];

				sprintf(cFileName, "scripts/%s", fileName.c_str());
				FILE*   f = fopen(cFileName, "rt");

				while (fgets(line, 256, f) != NULL)
				{
					// découpe la ligne en mots
					static std::vector<std::string>    words;
					words.clear();
					char* ptr;
					ptr = strtok(line," \t\r\n");

					while (ptr != NULL)
					{
						words.push_back(ptr);
						ptr = strtok(NULL, " \t\r\n");
					}

					// exécute la ligne
					executeLine(words);
				}
				fclose (f);
			}
		}
				
Cette fonction ouvre le fichier, en lit chaque ligne, la découpe en une liste de mots, et appelle la fonction
executeLine() pour executer la ligne donnée.
Maintenant regardons cette fonction:

		void CScripts::executeLine(std::vector<std::string>& words)
		{
			if (words[0] == "Target")
				executeTarget(words);
			else if (words[0] == "SetBool")
				executeSetBool(words);
		}
				
Elle est assez simple. Elle appelle seulement les fonctions executeTarget() ou executeSetBool() en fonction de
l'instruction de cette ligne.
Alors allons voir la fonction executeTarget():

		void CScripts::executeTarget(std::vector<std::string>& words)
		{
			int x = std::stoi(words[2]);
			int y = std::stoi(words[3]);
			CTile*  tile = map.getTile(CVec2(x, y));

			if (words[1] == "Tile")
			{
				mTargetType = eTargetType_Tile;
				mTarget = (void*)tile;
			}
			else
			{
				mTargetType = eTargetType_Wall;
				// mTarget = ...
			}
		}
				
Elle stocke un pointeur sur notre cible, qui peut soit être une case ou un mur.
Le code pour un mur n'est pas complet, parce qu'on a seulement besoin d'une case pour l'instant.
Et finalement regardons la fonction SetBool:

		void CScripts::executeSetBool(std::vector<std::string>& words)
		{
			QString param = QString::fromStdString(words[1]);
			bool    value = (words[2] == "true" ? true : false);

			if (mTargetType == eTargetType_Tile)
				((CTile*)mTarget)->setBoolParam(param, value);
			else
				((CWall*)mTarget)->setBoolParam(param, value);
		}
				
Ici on appelle la fonction setBoolParam() soit pour une case, soit pour un mur, avec les paramètres donnés.

Appeler le script

Enfin, parlons d'un dernier point important: quand appelons-nous ce script ?
Dans le cas d'une plaque de pression, comme elle peut être pressée de différentes façons (soit par nos champions,
par un monstre, ou par un objet), c'est plus simple de tester tous ces évènements au même endroit (sinon on devrait
ajouter du code dans le déplacement du joueur, dans le déplacement des ennemis, et à l'endroit où on pose un objet,
et ça amènerait des problèmes de synchronisation avec les autres éléments de notre code).

Donc j'ai écrit une fonction de mise à jour, comme celle qu'on a utilisée pour les portes, qui est appelée à chaque
frame:

		void CTiles::updatePressPlates()
		{
			for (int y = 0; y < map.mSize.y; ++y)
				for (int x = 0; x < map.mSize.x; ++x)
				{
					CVec2   pos(x, y);
					CTile*  tile = map.getTile(pos);

					bool&   state = pressPlateStates[y * map.mSize.x + x];
					bool    lastState = state;
					state = false;

					if (tile->getType() == eTilePressPlate)
					{
						if (player.pos == pos)
						{
							if (tile->getBoolParam("parChampions") == true && interface.isChampionEmpty(0) == false)
								state = true;
						}

						// marche sur une plaque de pression
						if (lastState == false && state == true)
							scripts.execute(tile->getScriptParam("siPressee"));

						// quitte une plaque de pression
						if (lastState == true && state == false)
							scripts.execute(tile->getScriptParam("siRelachee"));
					}
				}
		}
				
Ici on boucle sur toutes les cases de plaques de pression, on teste seulement si notre équipe est dessus (comme on
n'a pas encore d'ennemis ou d'objets) et on exécute le script correspondant si son état a changé (soit si elle est
pressée ou relachée).