Mécanique d’un jeu vidéo

Vous devez avoir parcouru le chapitre précédent La librairie G2D pour suivre ce chapitre.

Mettre en place l’interactivité

Dans un jeu vidéo, des éléments se déplacent en temps réel dans un monde 2D ou 3D. Contrairement à ce que vous pourriez penser, la fonction main() ne va pas gérer directement le jeu. En effet, elle est appelée comme habituellement au lancement du programme pour créer la fenêtre de l’application et éventuellement charger des ressources. Après cela, elle va continuer à gérer les évènements basiques : fermeture ou déplacement de la fenêtre d’affichage, mais elle n’aura pas de rôle direct dans le jeu. Ainsi, pour gérer l’interactivité, le système se chargera d’appeler deux fonctions spécifiques : l’une sera dédiée à l’affichage et l’autre à la gestion de la logique du jeu (déplacements, collisions, scores, clavier…).

Des données de partie sont partagées entre ces deux fonctions. Elles sont mises à jour par la fonction gérant la logique, mais par contre, elles sont uniquement accédées en lecture par la fonction gérant l’affichage. Il faut ainsi créer suffisamment de variables pour que la fonction d’affichage puisse déterminer ce qu’elle doit afficher. De cette façon, la fonction d’affichage n’interroge jamais la fonction gérant la logique. De cette façon, on garantit une séparation entre la gestion de l’affichage et le traitement de la logique du jeu. Ainsi, ce chapitre présente cette structuration particulièrement utilisée dans le monde du jeu vidéo.

Rôle de la fonction main()

Dans un programme en mode texte/console, une fonction main() sert de point d’entrée. Les différentes actions s’effectuent séquentiellement par rapport à leur ordre d’appel et une fois les traitements terminés, le programme s’arrête.

Mais, la mécanique d’une application graphique en C++ est différente. On trouve toujours une fonction main() comme point d’entrée, mais elle sert à mettre en place la fenêtre d’affichage :

int main(int argc, char* argv[])
{
        GameData G;   // instanciation de l'unique objet GameData qui sera passé aux fonctions render et logic

        G2D::initWindow(V2(G.WidthPix, G.HeighPix), V2(20, 20), string("G2D DEMO"));  // crée la fenêtre de l'application

        int callToLogicPerSec = 50;  // nombre de fois où la fonction Logic est appelée par seconde

        G2D::Run(Logic, render, G, callToLogicPerSec);  // lance l'application en spécifiant les deux fonctions utilisées et l'instance de GameData

        // aucun code ici
}

La fonction InitWindow(…) reçoit en arguments :

  • La position de la fenêtre donnée en pixels par rapport au coin haut-gauche de l’écran : V2(20,20).

  • La largeur et la hauteur de la fenêtre.

  • Le titre présent dans le bandeau de la fenêtre : string(« Test »)

Un objet GameData est instancié et passé en argument de la fonction Run(). Il permet de stocker les informations du jeu.

Pour terminer, la fonction Run() reçoit en arguments :

  • Une fonction Render() gérant les affichages.

  • Une fonction Logic() qui traite la logique du jeu : déplacement, collision, score, scénarios… mais n’affiche rien.

  • L’objet GameData contenant les informations de partie.

  • Un entier indiquant le nombre de fois par seconde où est appelée la fonction Logic().

Avertissement

La fonction Run() ne se terminera jamais. En effet, une fois qu’elle est appelée, elle va gérer les appels des deux fonctions principales Render() et Logic() en mode boucle infinie. Ainsi, tout code placé après la ligne G2D::Run(…) ne sera jamais exécuté.

Travail à effectuer

  • Décalez la fenêtre pour qu’elle ne se trouve plus dans le coin haut-gauche mais plutôt au milieu de votre écran.

  • Remplacez le titre « Test » dans le bandeau de la fenêtre de l’application pour « Mon Application ».

Séparation de la logique et de l’affichage

Il faut respecter le principe de la séparation de la logique et de l’affichage. C’est un principe de base dans les applications graphiques :

  • Tous les affichages doivent être faits dans la fonction Render(), la fonction Logic() ne doit effectuer aucun affichage.

  • La fonction Render() lit les informations de partie en mode lecture seule, elle ne doit pas les modifier. La fonction Logic() calcule les déplacements, le score…, en fonction des décisions prises, elle modifie les valeurs des paramètres de partie en conséquence.

  • Ce n’est pas parce que la fonction Logic() est dédiée aux tests que la fonction la fonction Render() ne peut traiter aucune condition. Par exemple, lorsque Pacman a avalé une super pacgum, il sera affiché en vert et plus en jaune. La fonction Render() peut donc aussi effectuer des conditions mais relativement à l’affichage, les conditions utilisées dans Render() ne doivent pas influer sur les données de partie.

L’architecture proposée ici est similaire à l’architecture MVC : Modèle / Vue / Contrôleur :

  • Le Modèle correspond aux données d’une page Web : la liste des articles dans le panier, l’identification du client…

  • La Vue correspond au front-end, c’est à dire la page affichée dans votre navigateur Web, bref, ce que vous voyez à l’écran.

  • Le Contrôleur gère la logique : lorsque l’utilisateur clique sur « Ajouter au panier », l’article est alors ajouté dans la liste des articles du client.

Dans l’architecture MVC, la séparation est assez intuitive car le modèle peut être stocké dans une base de données à distance, la partie affichage est essentiellement géré par le langage HTML et CSS et le contrôleur par le langage JavaScript. Le découpage est donc naturel car imposé par le contexte de travail.

Dans l’architecture d’un jeu vidéo, pourquoi ne pas avoir une seule fonction traitant la logique puis l’affichage ? Cela vient du fait que la gestion de la logique et de l’affichage sont assez indépendantes voir déconnectées. En effet, la fonction Logic() de G2D est appelée un certain nombre de fois par seconde, ceci à un rythme régulier. Cela nous garantit qu’il n’y a d’accélération ou de ralentissement dans l’animation. C’est exactement comme s’il y avait une horloge dans notre programme qui appelait la fonction Logic() avec un intervalle de temps constant. Par contre, nous n’avons pas de contrôle sur les appels à la fonction Render(). C’est le système d’exploitation et le driver graphique qui gère ces appels. Par exemple, lorsque la fenêtre de jeu est réduite ou cachée derrière une autre fenêtre, le système ne déclenche aucun appel à la fonction Render() car cela ne sert à rien. Les appels sont plus ou moins réguliers mais peuvent aussi être ralenti par d’autres applications qui effectuent des affichages à l’écran. Voici un exemple d’une séquence d’appels aux deux fonctions :

../_images/appels.png

Ainsi, il n’y a pas forcément autant d’appels de l’une et de l’autre fonction. Suivant la carte graphique, le type de moniteur, l’appel à la fonction Render() peut varier de 60 à 120 fois par seconde suivant l’ordinateur. Par contre l’appel à la fonction Logic() sera toujours de n fois par seconde quel que soit l’ordinateur.

La fonction Render()

Dans sa version actuelle :

void Render(const GameData& G)
{
        G2D::ClearScreen(Color::Black);
...
        G2D::Show();
}

La fonction Render() commence par effacer l’écran grâce à la fonction ClearScreen() en mettant un fond noir. Elle effectue ensuite divers tracés et se termine par la fonction Show() qui envoie le résultat à l’affichage.

Note

La fonction Show() doit toujours être appelée en dernier dans la fonction render(). Tout élément dessiné après risque de ne pas être affiché correctement.

Insérez la ligne suivante dans la fonction Render() :

cout << "Hello ! ";

En lançant le programme, vous obtenez l’affichage suivant :

../_images/hello.png

Ceci démontre que la fonction Render() est bien appelée automatiquement plusieurs fois par seconde.

La fonction Logic()

Dans sa version actuelle, la fonction Logic() est très simple. Si le jeu est en pause, elle n’effectue aucun traitement, ce qui a pour effet de bloquer le jeu. Cependant, les affichages continuent en parallèle, mais ils affichent cependant toujours la même chose. Un compteur idFrame est incrémenté, il sera utilisé pour gérer les animations.

void Logic()
{
   if (G2D::isOnPause())
          return;

   G.idFrame += 1;
}

Les données de partie

Nous regroupons les informations de jeu dans la structure GameData pour différentes raisons :

  • Pour éviter d’avoir les informations sur le jeu éparpillées dans le code à divers endroits.

  • Pour pouvoir instancier un seul objet G.

Dans la structure GameData, on trouve les variables WidthPix et HeighPix correspondant à la taille de la fenêtre de l’application. Cette structure GameData est instanciée dans la fonction main() en écrivant : GameData G;.

struct GameData
{
        int HeighPix   = 800;   // hauteur de la fenêtre d'application
        int WidthPix   = 600;   // largeur de la fenêtre d'application

        int idFrame = 0;        // compteur d'appels à la fonction Logic()
};

Avertissement

Ne vous mélangez pas les pinceaux, Gamedata est une structure, elle n’existe pas en mémoire. Son instance G nous permet, elle, de stocker les informations en mémoire.

Note

La struture GameData est placée en début de fichier. Cela permet à toutes les fonctions qui vont suivre de connaître son existence !

Si vous utilisez un environnement de qualité, en tapant dans le code G. vous aurez normalement une fenêtre de conseil qui s’ouvre et vous propose toutes les membres de G. Si vous continuez et tapez G.B, vous aurez uniquement les membres commençant par la lettre B :

../_images/hint.png

Lorsqu’il ne reste plus qu’un nom dans la liste des conseils, l’appui sur la touche TAB permet de compléter le nom en cours. On peut aussi utiliser les flèches haut et bas pour se déplacer dans cette liste. La touche TAB permet alors de compléter le code par la fonction sélectionnée.

Exercice

Nous allons créer un compte à rebours :

  • Créez une variable timing de type float dans la structure GameData.

  • Dans la fonction Logic(), positionnez cette variable à 10-G2D::elapsedTimeFromStartSeconds() si elle est positive, 0 sinon.

  • Dans la fonction Render(), affichez la valeur de la variable timing à l’écran.

Note

La fonction std::to_string(.) permet de convertir un float en string.

Timing et animation

Rythme d’appels à la fonction Logic()

Nous passons comme argument à la fonction Run() le nombre d’appels par seconde de la fonction Logic() :

int callToLogicPerSec = 50;
2D::Run(..., callToLogicPerSec,...);

Dans la fonction Logic(), nous trouvons la gestion d’un compteur :

G.idFrame += 1;

Ainsi, cette valeur augmente de 50 par seconde.

Vitesse de déplacement

Dans la fonction d’affichage, nous avons :

// affiche une bille qui roule
G2D::drawCircle(V2(G.idFrame, 20), 20, Color::Cyan, true);

Une sphère est dessinée en bas de la fenêtre aux coordonnées (G.idFrame,20). Ainsi, la sphère se déplace à une vitesse de 50 pixels / seconde. Comme la largeur de la fenêtre du jeu est de 600 pixels, cela nous permet de conclure que la boule met 600/50 = 12 secondes pour traverser l’écran.

Vitesse angulaire

Nous traitons le cas du segment qui tourne :

float angle = G.idFrame  /  100.0 * 3.141519;
V2 dir = 100 * V2(cos(angle), sin(angle));
V2 centre = V2(300, 300);
G2D::drawLine(centre-dir, centre+dir, Color::White);
G2D::drawCircle(centre, 5, Color::White, true);

Son angle est donné par :

\[angle = idFrame . \frac{\pi}{100}\]

Pour effectuer un tour complet, la variable angle doit valoir \(2\pi\). Cela se produit lorsque la variable idFrame vaut 200. Ainsi, à un rythme de 50 appels par seconde, le segment fait un tour complet en 200/50 = 4 secondes.

Vous pouvez aussi imposer la vitesse de rotation. Ainsi, avec l’équation :

\[angle = idFrame . c\]

Pour faire un tour complet en \(\Delta t\) secondes, nous devons résoudre l’équation :

\[2\pi = \Delta t . 50c\]

Ce qui donne :

\[c = \frac{2\pi}{50.\Delta t}\]