Cours programmation réseau en C++

Un premier jeu : Morpion

Bousk

Maintenant que notre moteur réseau commence à prendre forme, voyons comment l’utiliser dans un premier jeu très simple : le morpion.

Commentez

- Titre : Cours programmation réseau en C++

- Auteur : Bousk

- Parution : 3 septembre 2020

- Mise à jour : 27 août 2020

- 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 ® 2020 Cyrille (Bousk) Bousquet. 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. Introduction

- I-A. Difficultés du réseau dans un jeu

- II. Créer un jeu de morpion

- II-A. Au revoir la console

- II-B. Dépendances

- II-C. Le jeu solo

- II-C-1. Code du jeu

- II-D. Résultat attendu

- III. Préparation du projet

- IV. Introduction du multijoueur via le réseau

- IV-A. Objectif de l’article et règles du jeu

- IV-B. Peer 2 peer ?

- IV-C. Synchronisation des états

- IV-C-1. Diagrammes d’états

- IV-C-1-a. Diagramme d’états de l’hôte

- IV-C-1-b. Diagramme d’état du client

- IV-C-2. Interprétation et convergence

- IV-C-3. Convergence avec le jeu solo

- V. Implémentation du multijoueur

- V-A. Initialisation du réseau

- V-B. Ajout des différents états

- V-C. Initialisation de la partie

- V-D. Gestion du coup joué

- V-D-1. Mise en réseau du coup joué

- V-D-2. Jouer le coup et le répliquer

- V-E. Gestion du réseau

- V-E-1. Réception des données

- V-E-2. Traitement des messages réseau

- V-E-2-a. Demande de connexion

- V-E-2-b. Établissement de la connexion

- V-E-2-c. Déconnexion

- V-E-2-d. Gestion des données utilisateurs et des coups à jouer

- V-E-3. Envoi sur le réseau

- VI. Unification du code solo et multi

- VI-A. Introduction du service réseau

- VI-A-1. Interface du service réseau

- VI-A-2. Interface du Listener

- VI-A-2-a. void onServiceInitialized()

- VI-A-2-b. void onServiceReleased()

- VI-A-2-c. bool onIncomingConnection(const Bousk::Network::Messages::IncomingConnection&)

- VI-A-2-d. void onConnectionResult(const Bousk::Network::Messages::Connection&)

- VI-A-2-e. void onDisconnection(const Bousk::Network::Messages::Disconnection&)

- VI-A-2-f. void onDataReceived(const Bousk::Network::Messages::UserData&)

- VI-B. Implémentation du service réseau

- VI-B-1. Initialisation du service, pour une application réseau ou pas

- VI-B-1-a. Implémentation de l’initialisation

- VI-B-2. void receive, void flush, void sendTo

- VI-B-3. Traiter les messages reçus : transfert aux listeners

- VI-B-4. Accesseurs d’état

- VI-C. Mise à jour de la fonction main

- VI-C-1. Création d’un listener

- VI-C-2. Initialisation du service réseau

- VI-C-3. Initialisation du listener

- VI-C-4. Initialisation du jeu

- VI-C-5. Jouer un coup localement

- VI-C-6. Gestion du joueur ayant la main

- VI-C-7. Mise à jour du réseau

- VII. Conclusion

- VII-A. Code final

- VIII.

I - Introduction

Cet article s’insère dans le cours en présentant un exemple concret de l’utilisation du moteur réseau créé, dans des applications interactives 2D.

I-A - Difficultés du réseau dans un jeu

La mise en réseau d’un jeu est rarement simple et peut choquer certains membres d’un projet.

Dans un jeu solo et non connecté, toutes les données sont à disposition, alors qu’un jeu en réseau utilise lui des données reçues via le réseau pendant son exécution.

Ceci entraîne plusieurs types de problèmes :

- est-ce que des données sont disponibles ?

- Peut-être n’ont-elles pas encore été reçues ;

- ces données sont-elles correctes et de confiance ?

- Un joueur peut tricher et modifier ses données avant envoi ;

- est-ce que les données ne sont pas obsolètes et pour combien de temps sont-elles encore valides ?

En effet, si les données sont supposées correctes au moment de leur envoi – mettons qu’elles n’aient pas été altérées par quelconque moyen de triche –, l’acheminement via le réseau n’est pas instantané ! Nous travaillons donc essentiellement avec des données du passé, pour afficher au joueur une approximation du présent. Ou bien nous affichons un état passé et envoyons des données pour le futur. Quelle que soit votre formulation, et les deux cas existent selon le type de jeu, c’est aisément source de migraines.

Voici alors un des plus gros casse-tête de la mise en réseau d’un jeu : comment rendre l’expérience cohérente pour les joueurs ? Comment faire interagir les joueurs si les positions qu’ils ont en mémoire, correspondantes à la dernière réception, sont de toute façon obsolètes ? Comment leur faire croire que ce qu’ils voient n’est pas qu’une approximation ?

Mais avant de s’attaquer à ces problèmes, nous allons commencer par un cas simple : un jeu au tour par tour à deux joueurs. Ce premier jeu étudié est assez statique pour que son affichage soit simple. De plus, les joueurs utilisent un plateau de jeu et n’interagissent pas directement l’un sur l’autre. Probablement le cas de mise en réseau le plus simple.

II - Créer un jeu de morpion

Le but de cet article n’est pas de créer un jeu de morpion, mais de transformer un tel projet déjà existant uniquement hors ligne et solo vers une version deux joueurs communiquant potentiellement via internet.

Cet article suppose que vous soyez capable d’écrire le code du jeu solo, aussi très peu d’explications sur celui-ci seront fournies. Le code fourni sera minimal et loin d’un projet réel et ira droit au but. L’essentiel du code est directement inspiré du Wiki de la SDL2. Dans cet article, nous nous concentrerons sur la mise en réseau de ce code.

Le morpion (en anglais, TicTacToe) a été retenu pour sa simplicité. Il s’agit d’un jeu tour par tour et d’un projet réalisable en utilisant un unique message réseau : quel coup vient d’être joué.

II-A - Au revoir la console

Faire une application interactive est, selon moi, plus simple en 2D qu’en console. À fortiori quand le réseau est impliqué : si l’on utilise une communication UDP, il faut maintenir un flux d’échange de données pour maintenir la connexion et les différentes attentes d’entrées utilisateur bloquent le programme.

S’il est possible de manipuler des threads à cette fin, il est encore plus simple d’utiliser le mode non bloquant de notre socket vu dans les articles précédents, supprimant ainsi les problèmes de synchronisation.

Une version console est disponible dans les samples sur GitHub : https://github.com/Bousk/Net/tree/master/NetworkLib/Samples/Games/TicTacToe

II-B - Dépendances

Pour ce projet, les dépendances seront

- la SDL2 pour l’affichage ;

- la bibliothèque réseau créée dans le cours jusqu’à présent.

Le choix de SDL2 est arbitraire. Toute bibliothèque qui permet d’ouvrir une fenêtre, afficher des images et gérer la souris est valide. La SDL2 me semble la plus simple et légère puisque consistant en une unique DLL et n’ayant aucune dépendance.

II-C - Le jeu solo

Le but de l’article n’est pas de présenter la SDL2, ni d’expliquer comment l’utiliser. De tels articles sont disponibles ici par exemple.

Cet article n’est pas non plus destiné à apprendre comment architecturer un jeu.

La qualité du code peut donc être critiquée et il se contente d’aller au but : on charge les images puis les affiche dans la boucle de rendu. La quasi-totalité du code sera présente dans la fonction main, qui sera plutôt courte, assistée d‘une simple classe pour gérer la grille de jeu (conserver son état, jouer un coup et vérifier les conditions de fin de jeu). Il n’y aura pas de menu ou autre fioriture : le jeu se lance, puis on joue. Par simplicité, la barre de titre de la fenêtre est utilisée pour afficher des indications succinctes sur l’état de la partie. À la fin de la partie, on ferme l’exécutable que l’on doit relancer pour jouer une nouvelle partie.

II-C-1 - Code du jeu

Commençons par le code de gestion de la grille de jeu :

1. #pragma once

3. #include <array>

5. namespace TicTacToe

6. {

7. enum class Case

8. {

9. Empty,

10. X,

11. O,

12. };

13. class Grid

14. {

15. public:

16. Grid() = default;

17. ~Grid() = default;

19. // Jouer un coup pour le joueur passé en paramètre, aux coordonnées données. Retourne true si le coup est valide, false sinon.

20. bool play(unsigned int x, unsigned int y, Case player);

21. // Retourne true si la partie est terminée, false sinon.

22. bool isFinished() const { return mFinished; }

23. // Retourne le vainqueur, s’il y en a un, Case::Empty s’il s’agit d’un match nul.

24. Case winner() const { return mWinner; }

26. const std::array<std::array<Case, 3>, 3>& grid() const { return mGrid; }

28. private:

29. // Vérifie si la grille est pleine.

30. bool isGridFull() const;

32. private:

33. std::array<std::array<Case, 3>, 3> mGrid{ Case::Empty, Case::Empty, Case::Empty, Case::Empty, Case::Empty, Case::Empty, Case::Empty, Case::Empty, Case::Empty };

34. Case mWinner{ Case::Empty };

35. bool mFinished{ false };

36. };

37. }

Et son implémentation :

Game.cpp

1. #include <Game.hpp>

3. namespace TicTacToe

4. {

5. bool Grid::play(unsigned int x, unsigned int y, Case player)

6. {

7. if (x > 2 || y > 2)

8. return false;

9. if (mGrid[x][y] != Case::Empty)

10. return false;

12. mGrid[x][y] = player;

13. // Vérifier si le jeu est maintenant terminé

14. // Horizontale

15. bool justWon = (mGrid[x][0] == mGrid[x][1]) && (mGrid[x][1] == mGrid[x][2]);

16. // Verticale

17. justWon |= (mGrid[0][y] == mGrid[1][y]) && (mGrid[1][y] == mGrid[2][y]);

18. // Diagonale, si possible

19. if (x == y)

20. {

21. // Haut gauche vers bas droit

22. justWon |= (mGrid[0][0] == mGrid[1][1]) && (mGrid[1][1] == mGrid[2][2]);

23. }

24. if (x + y == 2)

25. {

26. // Bas gauche vers haut droit

27. justWon |= (mGrid[2][0] == mGrid[1][1]) && (mGrid[1][1] == mGrid[0][2]);

28. }

29. if (justWon)

30. {

31. mWinner = player;

32. mFinished = true;

33. }

34. else if (isGridFull())

35. {

36. mFinished = true;

37. }

38. return true;

39. }

40. bool Grid::isGridFull() const

41. {

42. for (unsigned x = 0; x < 3; ++x)

43. {

44. for (unsigned y = 0; y < 3; ++y)

45. {

46. if (mGrid[x][y] == Case::Empty)

47. return false;

48. }

49. }

50. return true;

51. }

52. }

Et enfin la fonction main :

Main.cpp

1. #include <Game.hpp>

2. #include <Net.hpp>

4. #include <SDL.h>

6. #include <string>

8. #define CASE_W (200)

9. #define CASE_H (200)

10. #define WIN_W (3*CASE_W)

11. #define WIN_H (3*CASE_H)

13. inline SDL_Texture* LoadTexture(const char* filepath, SDL_Renderer* renderer)

14. {

15. if (SDL_Surface* surface = SDL_LoadBMP(filepath))

16. {

17. SDL_SetColorKey(surface, SDL_TRUE, SDL_MapRGB(surface->format, 255, 174, 201));

18. SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer, surface);

19. SDL_FreeSurface(surface);

20. return texture;

21. }

22. return nullptr;

23. }

25. int main()

26. {

27. SDL_Init(SDL_INIT_VIDEO);

29. SDL_Window* window = SDL_CreateWindow("TicTacToe", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, WIN_W, WIN_H, SDL_WINDOW_OPENGL);

30. const std::string baseTitle = "TicTacToe";

31. auto updateWindowTitle = [&](const char* suffix)

32. {

33. std::string title = baseTitle + " - " + suffix;

34. SDL_SetWindowTitle(window, title.c_str());

35. };

36. SDL_Renderer* renderer = SDL_CreateRenderer(window, -1, SDL_RENDERER_ACCELERATED);

38. // Chargement des textures pour les cases : vide, X & O

39. std::array<SDL_Texture*, 3> plays{ LoadTexture("Empty.bmp", renderer), LoadTexture("X.bmp", renderer), LoadTexture("O.bmp", renderer) };

41. TicTacToe::Grid game;

42. const TicTacToe::Case players[2] = { TicTacToe::Case::X, TicTacToe::Case::O };

43. unsigned int playingPlayer = 0;

44. updateWindowTitle(players[playingPlayer] == TicTacToe::Case::X ? "X" : "O");

45. while (1)

46. {

47. const TicTacToe::Case currentPlayer = players[playingPlayer];

48. SDL_Event e;

49. if (SDL_PollEvent(&e))

50. {

51. if (e.type == SDL_QUIT)

52. {

53. break;

54. }

55. if (e.type == SDL_MOUSEBUTTONUP && !game.isFinished())

56. {

57. if (e.button.button == SDL_BUTTON_LEFT)

58. {

59. if (e.button.x >= 0 && e.button.x <= WIN_W

60. && e.button.y >= 0 && e.button.y <= WIN_H)

61. {

62. // Clic relâché sur la fenêtre : coup à jouer si valide

63. const unsigned int caseX = static_cast<unsigned int>(e.button.x / CASE_W);

64. const unsigned int caseY = static_cast<unsigned int>(e.button.y / CASE_H);

65. if (game.play(caseX, caseY, currentPlayer))

66. {

67. playingPlayer = (playingPlayer + 1) % 2;

68. updateWindowTitle(players[playingPlayer] == TicTacToe::Case::X ? "X" : "O");

69. }

70. }

71. }

72. }

73. }

75. SDL_SetRenderDrawColor(renderer, 255, 255, 255, SDL_ALPHA_OPAQUE);

76. SDL_RenderClear(renderer);

77. // Afficher les cases

78. for (int x = 0; x < 3; ++x)

79. {

80. for (int y = 0; y < 3; ++y)

81. {

82. const TicTacToe::Case caseStatus = game.grid()[x][y];

83. const SDL_Rect position{ x * CASE_W, y * CASE_H, CASE_W, CASE_H };

84. SDL_RenderCopy(renderer, plays[static_cast<unsigned int>(caseStatus)], NULL, &position);

85. }

86. }

87. // Afficher les lignes de la grille

88. SDL_SetRenderDrawColor(renderer, 0, 0, 0, SDL_ALPHA_OPAQUE);

89. // Horizontales

90. SDL_RenderDrawLine(renderer, 0, CASE_H, WIN_W, CASE_H);

91. SDL_RenderDrawLine(renderer, 0, CASE_H * 2, WIN_W, CASE_H * 2);

92. // Verticales

93. SDL_RenderDrawLine(renderer, CASE_W, 0, CASE_W, WIN_H);

94. SDL_RenderDrawLine(renderer, CASE_W * 2, 0, CASE_W * 2, WIN_H);

96. if (game.isFinished())

97. {

98. const TicTacToe::Case winner = game.winner();

99. if (winner == TicTacToe::Case::X)

100. updateWindowTitle("X wins");

101. else if (winner == TicTacToe::Case::O)

102. updateWindowTitle("O wins");

103. else

104. updateWindowTitle("Draw");

105. }

107. SDL_RenderPresent(renderer);

108. SDL_Delay(1);

109. }

111. for (SDL_Texture* texture : plays)

112. {

113. SDL_DestroyTexture(texture);

114. }

115. SDL_DestroyRenderer(renderer);

116. SDL_DestroyWindow(window);

117. SDL_Quit();

118. return 0;

119. }

II-D - Résultat attendu

Le résultat à ce stade est une fenêtre de jeu affichant les neuf cases et sa grille.

La barre de titre permet d’afficher le symbole du joueur actuel afin de suivre l’évolution du jeu – par simplicité en comparaison de l’affichage d’une telle information sur la fenêtre.

Après chaque coup, le jeu évolue et son affichage reste cohérent avec son nouvel état.

Le jeu peut se terminer sur un nul ou avec un symbole victorieux.

III - Préparation du projet

Afin de travailler sur ce projet, je vous conseille d’utiliser un gestionnaire de versions. J’en ai déjà parlé dans un précédent chapitre et son intérêt sera encore plus évident dans le cas présent.

En plus de pouvoir suivre vos changements et en avoir un historique pour votre projet actuel, ceci permet d’utiliser le projet de bibliothèque réseau comme sous-projet. L’avantage d’une telle installation est de pouvoir partager le code de cette bibliothèque avec tous vos projets qui l’utilisent. Chaque projet peut synchroniser la version de son choix, ou bien travailler avec la dernière version et récupérer la toute dernière version avec une simple resynchronisation. Chaque projet peut ainsi profiter très simplement des dernières avancées et implémentations.

Pour les projets de cette série d’articles, un nouveau dépôt GitHub a été créé. Ce premier projet se trouve à l’adresse https://github.com/Bousk/GamesSamples/tree/master/TicTacToe.

IV - Introduction du multijoueur via le réseau

IV-A - Objectif de l’article et règles du jeu

Le but de cet article est de faire évoluer le code du jeu solo présenté plus haut pour avoir une expérience multijoueur en réseau.

Cette expérience sera orientée peer 2 peer :

- un joueur lancera le jeu comme hôte ;

- un second joueur lancera comme client afin de se connecter à l’hôte ;

- une fois la connexion établie

- l’hôte jouera les X,

- le client jouera les O ;

- toute tentative de connexion supplémentaire sera refusée par l’hôte ;

- l’hôte sera le premier à jouer.

IV-B - Peer 2 peer ?

Le jeu sera en peer 2 peer (ou P2P), ce qui signifie qu’il n’y a pas de serveur réel, mais un joueur qui sera hôte et un autre client. Chaque joueur possède l’intégralité du code du jeu et celui-ci est dans un état similaire et cohérent chez chacun d’eux. On dit alors que les joueurs sont synchronisés : l’affichage de la partie est identique chez chacun.

Quand un joueur effectue une action, elle est directement appliquée sur l’exécutable local dont l’état peut changer suite à celle-ci, puis envoyée à l’adversaire afin qu’il la joue également et converge vers un état similaire pour garder un jeu cohérent entre eux et qu’ils restent synchronisés.

Il n’y aura pas ici de systèmes complexes ou protection pour s’assurer que les joueurs restent synchronisés.

Le code et le cas d’utilisation se veulent simples et en cas de problème (par exemple en cas de perte de connexion) il faudra redémarrer la partie.

IV-C - Synchronisation des états

La première chose évidente pour passer le code du jeu solo plus haut vers un code multijoueur en réseau sera l’ajout de synchronisation des états du jeu. Quand l’hôte lance le jeu, il doit attendre qu’un adversaire se connecte avant de démarrer la partie. Du côté du client, il faut attendre que la connexion soit validée avec l’hôte pour démarrer la partie.

L’hôte commencera donc dans un état « Attente de l’adversaire », et après avoir reçu une demande de connexion passe en état « Attente de validation de la connexion ».

De son côté, le client débute en demandant la connexion et en état « Attente de validation de la connexion ».

Une fois la connexion validée, les deux joueurs passent dans un état de jeu et doivent afficher une grille vide. L’hôte a alors la main pour jouer son coup, pendant que le client attend son tour. Une fois le coup de l’hôte joué, le client prend la main et c’est à l’hôte d’attendre son tour.

La partie se déroule ainsi jusqu’à la fin de la partie, avec un gagnant ou un match nul.

IV-C-1 - Diagrammes d’états

IV-C-1-a - Diagramme d’états de l’hôte

Voici un graphique des différents états et transitions du point de vue de l’hôte.

IV-C-1-b - Diagramme d’état du client

Et les états et transitions du point de vue du client

IV-C-2 - Interprétation et convergence

Sans surprise, les diagrammes d’états sont très similaires. Logique puisque seule l’initialisation est différente selon que l’exécutable soit en mode hôte ou client, la phase de jeu est ensuite en miroir.

Nous pouvons donc unifier ces deux diagrammes afin de créer un diagramme d’états de l’application qui sera ainsi :

IV-C-3 - Convergence avec le jeu solo

Nous pouvons également converger ces transitions avec la façon dont la version solo fonctionne.

En fait dans tous les cas, il n’y a que deux différences : l’initialisation et le changement de tour.

Concernant l’initialisation c’est assez évident puisqu’il faut initialiser un socket ou non si l’on est seul ou en réseau, puis dans le cas du réseau si l’on est hôte ou client.

Pour le changement de tour, il va s’agir de bloquer ou non les interactions avec le jeu si l’on a la main ou non : lors d’une partie réseau, nous avons la main uniquement pendant notre tour, pour un jeu solo, l’application a toujours le contrôle, mais le joueur/symbole qui interagit change.

V - Implémentation du multijoueur

V-A - Initialisation du réseau

Commençons par initialiser le réseau.

Ici deux cas se présentent :

- s’il s’agit d’un hôte, il faut initialiser le client UDP avec un port spécifique afin que le client le connaisse et puisse s’y connecter

- puis il faut attendre qu’un client se connecte pour commencer la partie ;

- s’il s’agit d’un client, il faut aussi initialiser le client UDP, mais dans ce cas le port utilisé importe peu

- puis nous devons initialiser une connexion vers l’hôte et attendre que celle-ci soit acceptée pour démarrer la partie.

Main.cpp

1. static constexpr Bousk::uint16 HostPort = 8888;

3. int main_p2p(const bool isHost)

4. {

5. if (!Bousk::Network::Start())

6. {

7. std::cout << "Erreur d’initialisation du reseau : " << Bousk::Network::Errors::Get();

8. return -1;

9. }

11. Bousk::Network::UDP::Client client;

12. client.registerChannel<Bousk::Network::UDP::Protocols::ReliableOrdered>();

13. // Initialiser le client avec n’importe quel port, l’hôte a un port fixe pour que le client puisse s’y connecter

14. const Bousk::uint16 port = isHost ? HostPort : 0;

15. if (!client.init(port))

16. {

17. std::cout << "Erreur d’initialisation du socket : " << Bousk::Network::Errors::Get();

18. Bousk::Network::Release();

19. return -2;

20. }

22. Bousk::Network::Address opponent;

23. if (!isHost)

24. {

25. const Bousk::Network::Address host = Bousk::Network::Address::Loopback(Bousk::Network::Address::Type::IPv4, HostPort);

26. opponent = host;

27. client.connect(host);

28. }

29. else

30. {

31. std::cout << "Attente de l’adversaire..." << std::endl;

32. }

33. …

34. }

V-B - Ajout des différents états

Nous pouvons maintenant ajouter les différents états vus dans les paragraphes précédents dans notre code :

Main.cpp

1. enum class State {

2. WaitingOpponent,

3. WaitingConnection,

4. MyTurn,

5. OpponentTurn,

6. Finished,

7. };

8. State state;

9. auto setState = [&](State newState)

10. {

11. state = newState;

12. switch (state)

13. {

14. case State::WaitingOpponent: updateWindowTitle("Attente de l’adversaire "); break;

15. case State::WaitingConnection: updateWindowTitle("Attente de la connexion"); break;

16. case State::MyTurn: updateWindowTitle("Mon tour"); break;

17. case State::OpponentTurn: updateWindowTitle("Tour de l’adversaire"); break;

18. case State::Finished: updateWindowTitle("Partie terminée"); break;

19. }

20. };

21. // L’hôte attend qu’un adversaire se connecte tandis que le client se connecte directement à l’hôte

22. setState(isHost ? State::WaitingOpponent : State::WaitingConnection);

V-C - Initialisation de la partie

L’initialisation est différente selon que l’exécutable est un hôte ou un client.

D’après les spécifications établies plus haut, l’hôte joue le premier et joue les X tandis que le client jouera les O :

Main.cpp

TicTacToe::Grid game;
// L’hôte joue les X, le client les O
const TicTacToe::Case localPlayerSymbol = isHost ? TicTacToe::Case::X : TicTacToe::Case::O;
// On sauvegarde aussi le symbole de l’adversaire par facilité pour la suite
const TicTacToe::Case opponentSymbol = (localPlayerSymbol == TicTacToe::Case::X) ? TicTacToe::Case::O : TicTacToe::Case::X;

V-D - Gestion du coup joué

V-D-1 - Mise en réseau du coup joué

Le coup à jouer devra être envoyé à l’adversaire. Il faut donc créer un message réseau à cette fin qui transférera les coordonnées de la case à jouer :

Net.hpp

1. #pragma once

3. #include <RangedInteger.hpp>

5. namespace Bousk

6. {

7. namespace Serialization

8. {

9. class Deserializer;

10. class Serializer;

11. }

12. }

13. namespace TicTacToe

14. {

15. namespace Net

16. {

17. struct Play

18. {

19. Bousk::RangedInteger<0, 2> x;

20. Bousk::RangedInteger<0, 2> y;

22. bool write(Bousk::Serialization::Serializer&) const;

23. bool read(Bousk::Serialization::Deserializer&);

24. };

25. }

26. }

Le symbole du coup n’a pas besoin d’être sérialisé : il s’agit du symbole de l’adversaire que nous connaissons par élimination avec notre propre symbole et est sauvegardé à l’initialisation.

Puis l’implémentation de cette structure :

Net.cpp

1. #include <Net.hpp>

2. #include <Serialization/Deserializer.hpp>

3. #include <Serialization/Serializer.hpp>

5. namespace TicTacToe

6. {

7. namespace Net

8. {

9. bool Play::write(Bousk::Serialization::Serializer& stream) const

10. {

11. return stream.write(x)

12. && stream.write(y);

13. }

14. bool Play::read(Bousk::Serialization::Deserializer& stream)

15. {

16. return stream.read(x)

17. && stream.read(y);

18. }

19. }

20. }

V-D-2 - Jouer le coup et le répliquer

Il faut maintenant modifier comment sont gérés les coups joués. Le coup doit être bloqué tant que ce n’est pas son tour, puis, une fois le coup joué et validé, il faut envoyer le coup en question à l’adversaire afin qu’il fasse avancer sa propre représentation de la partie :

Main.cpp

1. if (e.type == SDL_MOUSEBUTTONUP && state == State::MyTurn)

2. {

3. if (e.button.button == SDL_BUTTON_LEFT)

4. {

5. if (e.button.x >= 0 && e.button.x <= WIN_W

6. && e.button.y >= 0 && e.button.y <= WIN_H)

7. {

8. // Transformation des coordonnées de la souris en case du jeu

9. const unsigned int caseX = static_cast<unsigned int>(e.button.x / CASE_W);

10. const unsigned int caseY = static_cast<unsigned int>(e.button.y / CASE_H);

11. if (game.play(caseX, caseY, localPlayerSymbol))

12. {

13. TicTacToe::Net::Play msg;

14. msg.x = caseX;

15. msg.y = caseY;

16. Bousk::Serialization::Serializer serializer;

17. if (!msg.write(serializer))

18. {

19. std::cout << "Erreur critique : sérialisation du paquet échouée" << std::endl;

20. assert(false);

21. }

22. client.sendTo(opponent, serializer.buffer(), serializer.bufferSize(), 0);

23. setState(State::OpponentTurn);

24. }

25. }

26. }

27. }

Dans un cas réel, il n’est probablement pas correct de fermer le jeu si la sérialisation échoue. En cas d’échec, il faudrait plutôt annuler le coup localement, afficher une erreur puis laisser le joueur jouer un autre coup – en espérant que cette fois la sérialisation réussisse.

En pratique, la sérialisation n’a quasiment aucune chance d’échouer dans notre cas. Et en cas d’erreur de sérialisation, ce sont généralement des problèmes à corriger avant de sortir l’application.

V-E - Gestion du réseau

V-E-1 - Réception des données

Pour pouvoir utiliser la couche réseau, il faut commencer par recevoir les données de celle-ci via un simple appel à client.receive();

V-E-2 - Traitement des messages réseau

Après leur réception, nous devons traiter les messages extraits depuis le réseau.

Ici nous avons quatre cas pour chacun des quatre messages qui existent dans le moteur à date : demande de connexion, connexion établie, déconnexion et données utilisateurs qui seront les coups à jouer puisque c’est l’unique message que nous utilisons.

Le traitement des messages doit suivre leur réception :

Main.cpp

auto messages = client.poll();
for (const auto& msg : messages)
{
if (msg->is<Bousk::Network::Messages::IncomingConnection>())
{
…
}
else if (msg->is<Bousk::Network::Messages::Connection>())
{
…
}
else if (msg->is<Bousk::Network::Messages::UserData>())
{
…
}
else if (msg->is<Bousk::Network::Messages::Disconnection>())
{
…
}
}

Le traitement de chaque message est détaillé dans les paragraphes ci-dessous.

V-E-2-a - Demande de connexion

La demande de connexion ne concerne que l’hôte. S’il est toujours en attente d’un adversaire, il doit accepter la connexion, puis attendre que celle-ci soit établie afin de démarrer la partie – ce qui est détaillé dans le paragraphe suivant.

Main.cpp

if (msg->is<Bousk::Network::Messages::IncomingConnection>())
{
if (isHost && state == State::WaitingOpponent)
{
// Accepter l’adversaire
opponent = msg->emitter();
client.connect(opponent);
setState(State::WaitingConnection);
}
}

V-E-2-b - Établissement de la connexion

Quand la confirmation de connexion est reçue, les actions dépendent de si la connexion est réussie ou non et s’il s’agit de l’hôte ou du client.

Si le client échoue à se connecter, alors il devra relancer le programme pour retenter de se connecter.

Si l’hôte voit son adversaire échouer la connexion, il peut retourner en attente d’adversaire.

Si la connexion est réussie, alors la partie débute. L’hôte jouera le premier, l’adversaire commence par attendre que l’hôte joue son coup pour pouvoir jouer.

Main.cpp

else if (msg->is<Bousk::Network::Messages::Connection>())
{
if (state == State::WaitingConnection)
{
const Bousk::Network::Messages::Connection* connectionMsg = msg->as<Bousk::Network::Messages::Connection>();
if (connectionMsg->result == Bousk::Network::Messages::Connection::Result::Success)
{
// L’hôte joue en premier
setState(isHost ? State::MyTurn : State::OpponentTurn);
}
else if (isHost)
{
// Retour à l’attente d’adversaire
setState(State::WaitingOpponent);
}
}
}

V-E-2-c - Déconnexion

À tout moment une déconnexion peut se produire. Lors d’une déconnexion, le jeu est mis dans un état déconnecté, la partie est alors terminée et les joueurs devront relancer le jeu pour recommencer.

Main.cpp

else if (msg->is<Bousk::Network::Messages::Disconnection>())
{
updateWindowTitle("Déconnecté");
}

V-E-2-d - Gestion des données utilisateurs et des coups à jouer

Enfin, nous devons gérer les données utilisateur. L’unique message que nous transférons est le TicTacToe::Net::Play vu plus haut. Mais le moteur réseau ne connaît pas ce type : il ne traite que des tampons et c’est à l’application de traiter ces données.

Il faudra donc désérialiser le tampon afin de recréer la structure Play, pour pouvoir l’utiliser :

Main.cpp

else if (msg->is<Bousk::Network::Messages::UserData>())
{
const Bousk::Network::Messages::UserData* data = msg->as<Bousk::Network::Messages::UserData>();
Bousk::Serialization::Deserializer deserializer(data->data.data(), data->data.size());
TicTacToe::Net::Play play;
if (!play.read(deserializer))
{
std::cout << "Erreur critique : désérialisation du paquet Play échouée " << std::endl;
assert(false);
}
…
}

Puis une fois que la structure est ainsi correctement créée, nous pouvons jouer le coup correspondant afin de faire évoluer l’état du jeu :

Main.cpp

else if (msg->is<Bousk::Network::Messages::UserData>())
{
…
assert(state == State::OpponentTurn);
if (!game.play(play.x, play.y, opponentSymbol))
{
std::cout << "Erreur critique : impossible de jouer le coup de l’adversaire " << std::endl;
assert(false);
}
setState(State::MyTurn);
}

Si tout se passe correctement, après avoir joué le coup de l’adversaire le joueur local prend la main pour jouer son propre coup.

V-E-3 - Envoi sur le réseau

Enfin, il faut aussi envoyer nos données sur le réseau afin que l’adversaire les reçoive.

Ceci se fait via un simple appel à client.processSend(); à l’endroit de votre choix.

Typiquement, cet appel pourra se trouver en fin de frame afin d’envoyer ce qui a été mis en file d’envoi pendant la frame.

Dans notre cas, nous pouvons le placer directement après la gestion des entrées utilisateurs afin de bénéficier de l’exécution de la frame pour que le réseau commence à émettre les données, puisque c’est l’unique source de données à envoyer.

Dans un projet plus complexe, il peut être important d’identifier le meilleur endroit pour réaliser cet envoi.

Un parfait candidat se trouve juste après l’exécution du gameplay, si celui-ci peut mettre des données en file d’envoi, avant le début de l’affichage afin que le système puisse commencer à envoyer ces données pendant que l’affichage se réalise.

Ceci permet de gagner quelques précieuses millisecondes sur la réplication.

VI - Unification du code solo et multi

À ce stade, nous avions un code solo fonctionnel, puis avons pu établir un code multi fonctionnel sur cette base solo.

Nous pouvons maintenant unifier ces deux codes afin d’avoir un code unique qui prenne en compte le type de jeu que nous exécutons afin d’avoir une expérience solo ou multi cohérente.

VI-A - Introduction du service réseau

Afin de pouvoir converger le code, nous allons introduire un nouvel élément dans l’application : le service réseau. Ceci peut aussi s’appeler acteur réseau, mais vu qu’il est l’unique acteur présent dans notre cas, cette appellation semble moins probante.

Un service est une classe qui permet d’interagir avec un composant plus bas niveau et sert d’interface au reste de l’application. Cette classe proposera une interface entre le moteur réseau et l’application et définira l’état du réseau : en ligne, hors ligne, hôte ou client.

Le service réseau sera une interface englobante le client UDP – dans notre cas, mais il peut également être utilisé pour un client ou serveur TCP. En termes de réseau, l’application ne travaillera donc pas directement avec le client UDP, mais avec le service. Cette coupe permet de changer le type de client réseau utilisé, de passer du TCP à l’UDP ou inversement, plus aisément.

Cette interface permettra de prévenir l’application quand l’état change parce que la connexion est perdue par exemple, via un système de listener : une interface dont pourront hériter les objets qui veulent écouter les évènements du service.

VI-A-1 - Interface du service réseau

Pour la première version de ce service, il s’agira principalement d’une surcouche sur un objet UDPClient de la bibliothèque réseau, suivant le patron de conception de l’observateur (pattern Observer). Le service devra être initialisé avec une structure de paramètres, puis permettra d’envoyer et recevoir les différents messages existants.

Quelques accesseurs permettront de connaître l’état du service.

Enfin, les listeners devront pouvoir s’enregistrer ou s’enlever du service.

NetService.hpp

1. #pragma once

3. #include <Messages.hpp>

4. #include <UDP/UDPClient.hpp>

6. #include <unordered_set>

8. class NetService

9. {

10. public:

11. struct Parameters

12. {

13. };

14. class IListener

15. {

16. protected:

17. virtual ~IListener() = default;

18. };

19. public:

20. bool init(const Parameters& parameters);

21. void release();

23. void receive();

24. void process();

25. void flush();

27. inline void addListener(IListener* listener) { mListeners.insert(listener); }

28. inline void removeListener(IListener* listener) { mListeners.erase(listener); }

30. inline bool isInitialized() const { return mState == State::Initialized; }

32. void sendTo(const Bousk::Network::Address& target, const Bousk::uint8* data, const size_t datasize);

34. private:

35. std::unordered_set<IListener*> mListeners;

36. Bousk::Network::UDP::Client mUdpClient;

37. Parameters mContext;

38. enum class State {

39. Idle,

40. Initialized,

41. };

42. State mState{ State::Idle };

43. };

VI-A-2 - Interface du Listener

Un listener permettra de recevoir une callback quand certains évènements se produisent.

Plus spécifiquement, nous enverrons un évènement quand

- une connexion est demandée ;

- une connexion réussit ou échoue ;

- une déconnexion est détectée ;

- des données sont reçues.

En plus de celles-ci, nous prévoyons un évènement pour les cas où

- le service est initialisé avec succès ;

- le service est terminé.

Ainsi, l’ensemble de l’application peut savoir ce qu’il se passe au sein du service et réagir en conséquence.

NetService.hpp

class IListener
{
public:
virtual void onServiceInitialized() {}
virtual void onServiceReleased() {}

virtual bool onIncomingConnection(const Bousk::Network::Messages::IncomingConnection&) { return true; }
virtual void onConnectionResult(const Bousk::Network::Messages::Connection&) {}
virtual void onDisconnection(const Bousk::Network::Messages::Disconnection&) {}
virtual void onDataReceived(const Bousk::Network::Messages::UserData&) {}

protected:
virtual ~IListener() = default;
};

VI-A-2-a - void onServiceInitialized()

Cet évènement permet d’informer que le service est maintenant prêt à être utilisé.

Si le service a été initialisé en tant qu’hôte, des connexions peuvent maintenant être reçues, ou émises.

S’il l’a été en tant que client, cela signifie que la connexion est en cours.

VI-A-2-b - void onServiceReleased()

Cet évènement survient quand le service est déinitialisé et ne devrait plus être utilisé avant qu’une nouvelle initialisation ait eu lieu.

VI-A-2-c - bool onIncomingConnection(const Bousk::Network::Messages::IncomingConnection&)

Cet évènement survient quand une connexion entrante est reçue.

Un listener peut via cette callback refuser une connexion entrante en retournant false. Cette callback retourne true dans l’interface de base afin d’accepter toutes les connexions par défaut.

VI-A-2-d - void onConnectionResult(const Bousk::Network::Messages::Connection&)

Cet évènement permet de notifier d’une nouvelle connexion établie.

VI-A-2-e - void onDisconnection(const Bousk::Network::Messages::Disconnection&)

Cet évènement indique qu’une machine précédemment connectée ne l’est plus.

VI-A-2-f - void onDataReceived(const Bousk::Network::Messages::UserData&)

Cet évènement permet d’extraire le tampon de données utilisateur reçu par le socket.

VI-B - Implémentation du service réseau

Après avoir défini l’interface du service et le fonctionnement des listeners, passons à l’implémentation du service.

VI-B-1 - Initialisation du service, pour une application réseau ou pas

Le but du service est de pouvoir supporter le jeu lorsqu’on est hôte, client, ou que l’on joue seul sans support réseau.

Quand initialisé comme hôte, le service créera le socket puis sera immédiatement prêt à recevoir des connexions.

Quand initialisé en tant que client, le service démarrera une connexion vers l’hôte dès que le socket est créé.

Quand initialisé en mode solo, le socket ne sera pas créé ni jamais utilisé, ne créant pas de surcoût d’utilisation.

Traduisons ceci dans notre structure de paramètres d’initialisation :

NetService.hpp

1. struct Parameters

2. {

3. Bousk::Network::Address hostAddress;

4. Bousk::uint16 localPort{ 0 };

5. bool networked{ false };

6. bool host{ false };

7. };

VI-B-1-a - Implémentation de l’initialisation

L’implémentation de l’initialisation devrait être assez évidente. Le socket est initialisé si nécessaire, et la connexion est démarrée s’il s’agit de l’initialisation d’un client.

NetService.cpp

1. bool NetService::init(const Parameters& parameters)

2. {

3. if (isInitialized())

4. return false;

5. if (parameters.networked)

6. {

7. if (!Bousk::Network::Start())

8. {

9. return false;

10. }

11. if (!mUdpClient.init(parameters.localPort))

12. return false;

13. if (!parameters.host)

14. {

15. // S’il s’agit d’un client, démarrer la connexion immédiatement

16. mUdpClient.connect(parameters.hostAddress);

17. }

18. }

19. mContext = parameters;

20. mState = State::Initialized;

21. for (IListener* listener : mListeners)

22. {

23. listener->onServiceInitialized();

24. }

25. return true;

26. }

N’oubliez pas de copier les paramètres dans le contexte du service afin de pouvoir y accéder plus tard – certains sont inutiles, mais il est plus simple d’utiliser la même structure, du moins dans un premier temps.

Pour notre premier jeu, nous n’utiliserons qu’un canal ordonné fiable. Afin d’initialiser ce canal une seule fois, son ajout sera fait dans le constructeur et non lors de l’initialisation afin que le client UDP interne en possède bien un unique et non un pour chaque appel à la fonction init.

NetService.cpp

1. NetService::NetService()

2. {

3. mUdpClient.registerChannel<Bousk::Network::UDP::Protocols::ReliableOrdered>();

4. }

VI-B-2 - void receive, void flush, void sendTo

La réception, la réalisation de l’envoi et la mise en file d’envoi de données seront de simples redirections vers les appels respectifs du client UDP.

La seule subtilité est de s’assurer de faire ces appels uniquement si c’est nécessaire : si le service est initialisé et que le client UDP est lui aussi initialisé – autrement dit, si le service n’est pas initialisé en mode hors ligne.

NetService.cpp

1. void NetService::receive()

2. {

3. if (isInitialized() && isNetworked())

4. mUdpClient.receive();

5. }

6. void NetService::flush()

7. {

8. if (isInitialized() && isNetworked())

9. mUdpClient.processSend();

10. }

11. void NetService::sendTo(const Bousk::Network::Address& target, const Bousk::uint8* data, const size_t datasize)

12. {

13. if (isInitialized() && isNetworked())

14. mUdpClient.sendTo(target, data, datasize, 0);

15. }

VI-B-3 - Traiter les messages reçus : transfert aux listeners

La dernière étape est de traiter les messages reçus du client pour les transférer aux listeners enregistrés au service.

Il s’agit d’extraire ces messages, puis de les gérer selon leur type comme c’était fait dans la boucle principale du programme jusqu’à présent.

NetService.cpp

1. void NetService::process()

2. {

3. if (isInitialized() && isNetworked())

4. {

5. auto messages = mUdpClient.poll();

6. for (const auto& msg : messages)

7. {

8. if (msg->is<Bousk::Network::Messages::IncomingConnection>())

9. {

10. if (isHost())

11. {

12. bool acceptConnection = true;

13. // Seul l’hôte peut accepter des connexions. Les clients les ignoreront silencieusement.

14. for (IListener* listener : mListeners)

15. {

16. acceptConnection &= listener->onIncomingConnection(*(msg->as<Bousk::Network::Messages::IncomingConnection>()));

17. }

18. if (acceptConnection)

19. mUdpClient.connect(msg->emitter());

20. }

21. }

22. else if (msg->is<Bousk::Network::Messages::Connection>())

23. {

24. for (IListener* listener : mListeners)

25. {

26. listener->onConnectionResult(*(msg->as<Bousk::Network::Messages::Connection>()));

27. }

28. }

29. else if (msg->is<Bousk::Network::Messages::UserData>())

30. {

31. for (IListener* listener : mListeners)

32. {

33. listener->onDataReceived(*(msg->as<Bousk::Network::Messages::UserData>()));

34. }

35. }

36. else if (msg->is<Bousk::Network::Messages::Disconnection>())

37. {

38. for (IListener* listener : mListeners)

39. {

40. listener->onDisconnection(*(msg->as<Bousk::Network::Messages::Disconnection>()));

41. }

42. }

43. }

44. }

45. }

Toutes les redirections sont très immédiates, sauf celle de connexion entrante : celle-ci attend un bool en retour qui définit si la connexion doit être acceptée ou non. Ainsi, tous les listeners qui veulent pouvoir agir sur une nouvelle connexion entrante, peuvent décider si la connexion peut être acceptée – si tous les listeners retournent true -, ou refuser – si l’un d’eux retourne false.

VI-B-4 - Accesseurs d’état

Enfin, on ajoute des accesseurs permettant de connaître l’état du service.

NetService.hpp

class NetService
{
public:
…
inline bool isInitialized() const { return mState == State::Initialized; }
inline bool isNetworked() const { return mContext.networked; }
inline bool isHost() const { return mContext.host; }
…
};

VI-C - Mise à jour de la fonction main

Avec ce nouveau service réseau, nous pouvons mettre à jour la fonction principale du programme afin de l’utiliser et d’unifier le code qui sera maintenant similaire, voire identique dans les trois cas : où l’on est hôte, client ou deux joueurs sans support réseau.

VI-C-1 - Création d’un listener

Commençons par créer un listener. Celui-ci sera très générique et utilisera des std::function que nous pourrons assigner dans la fonction main afin d’agir avec les variables présentes dans le programme.

Main.cpp

#include <NetService.hpp>

class NetListener : public NetService::IListener
{
using OnIncomingConnectionCallback = std::function<bool(const Bousk::Network::Messages::IncomingConnection&)>;
using OnConnectionCallback = std::function<void(const Bousk::Network::Messages::Connection&)>;
using OnDisconnectionCallback = std::function<void(const Bousk::Network::Messages::Disconnection&)>;
using OnDataReceivedCallback = std::function<void(const Bousk::Network::Messages::UserData&)>;
public:
OnIncomingConnectionCallback mOnIncomingConnection;
OnConnectionCallback mOnConnectionResult;
OnDisconnectionCallback mOnDisconnection;
OnDataReceivedCallback mOnDataReceived;

private:
bool onIncomingConnection(const Bousk::Network::Messages::IncomingConnection& incomingConnection) override
{
return !mOnIncomingConnection || mOnIncomingConnection(incomingConnection);
}

void onConnectionResult(const Bousk::Network::Messages::Connection& connection) override
{
if (mOnConnectionResult)
mOnConnectionResult(connection);
}

void onDisconnection(const Bousk::Network::Messages::Disconnection& disconnection) override
{
if (mOnDisconnection)
mOnDisconnection(disconnection);
}

void onDataReceived(const Bousk::Network::Messages::UserData& userdata) override
{
if (mOnDataReceived)
mOnDataReceived(userdata);
}
};

Dans un programme plus conséquent, il est préférable d’avoir des listeners spécifiques qui interagissent directement avec les éléments du programme plutôt que d’avoir recours à des std::function génériques. Ces listeners auront également probablement des données membres puisque faisant partie d’un tout, là où cet exemple est très simple, voire trivial sur cet aspect.

VI-C-2 - Initialisation du service réseau

Nous pouvons maintenant modifier l’initialisation du programme pour passer via le service réseau plutôt que la bibliothèque réseau directement.

Main.cpp

int main_merged(const bool isNetworked, const bool isHost = false)
{
// Allouer le service dans la heap pour éviter l’avertissement de taille de la stack trop grande
std::unique_ptr<NetService> netService = std::make_unique<NetService>();
{
NetService::Parameters netServiceParameters;
netServiceParameters.networked = isNetworked;
netServiceParameters.host = isNetworked && isHost;
netServiceParameters.localPort = isHost ? HostPort : 0;
if (!isHost)
netServiceParameters.hostAddress = Bousk::Network::Address::Loopback(Bousk::Network::Address::Type::IPv4, HostPort);
if (!netService->init(netServiceParameters))
{
std::cout << "Erreur initialisation du NetService : " << Bousk::Network::Errors::Get();
return -1;
}
}

VI-C-3 - Initialisation du listener

Une instance de listener sera créée pour gérer les messages réseau.

Ce listener doit accepter le premier client dans le cas du serveur, puis initialiser le jeu quand la connexion est établie.

Il doit aussi gérer les données des utilisateurs pour les désérialiser et créer le coup à jouer de son adversaire.

Enfin il doit prendre en compte les déconnexions afin d’informer le joueur et arrêter la partie.

Main.cpp

// Servira pour sauvegarder l’adresse de l’adversaire
Bousk::Network::Address opponent;

NetListener netListener;
netService->addListener(&netListener);
netListener.mOnIncomingConnection = [&](const Bousk::Network::Messages::IncomingConnection& msg)
{
if (netService->isHost() && state == State::WaitingOpponent)
{
setState(State::WaitingConnection);
return true;
}
return false;
};
netListener.mOnConnectionResult = [&](const Bousk::Network::Messages::Connection& msg)
{
if (state == State::WaitingConnection)
{
if (msg.result == Bousk::Network::Messages::Connection::Result::Success)
{
// Sauvegarder l’adresse de l’adversaire (pour pouvoir lui envoyer les données des coups à jouer)
opponent = msg.emitter();
// L’hôte joue premier
setState(netService->isHost() ? State::MyTurn : State::OpponentTurn);
}
else if (netService->isHost())
{
// Retour à l’attente d’un adversaire
setState(State::WaitingOpponent);
}
}
};
netListener.mOnDataReceived = [&](const Bousk::Network::Messages::UserData& msg)
{
Bousk::Serialization::Deserializer deserializer(msg.data.data(), msg.data.size());
TicTacToe::Net::Play play;
if (!play.read(deserializer))
{
std::cout << "Erreur critique : Impossible de désérialiser le message de coup " << std::endl;
assert(false);
}
assert(state == State::OpponentTurn);
if (!playCurrentTurnLocally(play.x, play.y))
{
std::cout << "Erreur critique : impossible de jouer le coup " << std::endl;
assert(false);
}
};
netListener.mOnDisconnection = [&](const Bousk::Network::Messages::Disconnection& msg)
{
updateWindowTitle("Disconnected");
};

VI-C-4 - Initialisation du jeu

Nous avons dans un diagramme précédent vu comment l’initialisation devrait désormais être faite.

Nous devons donc modifier l’initialisation de l’état du programme en conséquence :

Main.cpp

enum class State {
WaitingOpponent,
WaitingConnection,
MyTurn,
OpponentTurn,
Finished,
};
State state;
auto setState = [&](State newState)
{
state = newState;
switch (state)
{
case State::WaitingOpponent: updateWindowTitle("Attente de l’adversaire"); break;
case State::WaitingConnection: updateWindowTitle("Connexion en cours"); break;
case State::MyTurn: updateWindowTitle(netService->isNetworked() ? "Mon tour" : "Tour du joueur 1"); break;
case State::OpponentTurn: updateWindowTitle(netService->isNetworked() ? "Tour de l’adversaire" : "Tuor du joueur 2"); break;
case State::Finished: updateWindowTitle("Partie terminée"); break;
}
};
if (netService->isNetworked())
setState(netService->isHost() ? State::WaitingOpponent : State::WaitingConnection);
else
setState(State::MyTurn);

VI-C-5 - Jouer un coup localement

Le jeu suit le modèle P2P : chaque joueur joue le coup reçu localement avant de passer l’état du programme au joueur suivant – local ou non.

Le plus simple est alors d’avoir une variable indiquant quel joueur (symbole) a actuellement la main, et c’est cette variable qui sera utilisée quand un coup est joué.

Il est alors intéressant d’avoir une fonction à cette fin :

Main.cpp

// L’hôte joue les X, le client les O
const std::array<TicTacToe::Case, 2> players{ TicTacToe::Case::X, TicTacToe::Case::O };
uint8_t currentPlayingPlayer = 0;
auto playCurrentTurnLocally = [&](unsigned int x, unsigned int y)
{
const TicTacToe::Case currentPlayerSymbol = players[currentPlayingPlayer];
if (game.play(x, y, currentPlayerSymbol))
{
// Si le coup est valide, changer le joueur qui a la main au suivant
currentPlayingPlayer = (currentPlayingPlayer + 1) % 2;
setState(state == State::OpponentTurn ? State::MyTurn : State::OpponentTurn);
return true;
}
return false;
};

VI-C-6 - Gestion du joueur ayant la main

Il faudra modifier la gestion du joueur qui a la main.

Si le jeu est en réseau, le joueur local doit jouer uniquement quand c’est son tour. Dans le cas d’un jeu hors ligne, le joueur est toujours local et c’est la fonction playCurrentTurnLocally ci-dessus qui fera la transition d’un joueur à l’autre.

Quand le joueur veut jouer, on vérifie donc : qu’il ait la main et que le coup soit valide. Si tout est OK, alors on joue le coup localement, puis on envoie les informations du coup à l’adversaire pour qu’il le joue de son côté également – si le jeu est en réseau.

Main.cpp

SDL_Event e;
if (SDL_PollEvent(&e))
{
if (e.type == SDL_QUIT)
{
break;
}
if (e.type == SDL_MOUSEBUTTONUP && (state == State::MyTurn || !netService->isNetworked()))
{
if (e.button.button == SDL_BUTTON_LEFT)
{
if (e.button.x >= 0 && e.button.x <= WIN_W
&& e.button.y >= 0 && e.button.y <= WIN_H)
{
// Vérifier que le coup est valide
const unsigned int caseX = static_cast<unsigned int>(e.button.x / CASE_W);
const unsigned int caseY = static_cast<unsigned int>(e.button.y / CASE_H);
if (playCurrentTurnLocally(caseX, caseY))
{
if (netService->isNetworked())
{
TicTacToe::Net::Play msg;
msg.x = caseX;
msg.y = caseY;
Bousk::Serialization::Serializer serializer;
if (!msg.write(serializer))
{
std::cout << "Erreur critique : impossible de sérialiser le coup " << std::endl;
assert(false);
}
netService->sendTo(opponent, serializer.buffer(), serializer.bufferSize());
}
}
}
}
}
}

VI-C-7 - Mise à jour du réseau

La dernière étape est la mise à jour du réseau : réception des données, gestion des messages reçus via dispatch aux listeners et envoi des données en file d’envoi.

Main.cpp

// Réception des données réseau
netService->receive();
// Dispatch des messages reçus
netService->process();
// Envoi des données sur le réseau
netService->flush();

VII - Conclusion

Nous avons désormais la possibilité d’avoir un programme qui permet de supporter un jeu purement local ou via le réseau avec deux joueurs tour par tour, dont l’un est l’hôte – ce qui s’apparente à du P2P.

Le code pourrait encore être amélioré, mais nous nous contenterons de cette version pour le moment.

Ceci nous servira de base pour de prochains articles, dans lesquels le code continuera d’évoluer.

VII-A - Code final

Main.cpp

1. #include <main.hpp>

3. #include <Errors.hpp>

4. #include <Messages.hpp>

5. #include <Serialization/Deserializer.hpp>

6. #include <Serialization/Serializer.hpp>

8. #include <NetService.hpp>

10. #include <iostream>

12. static constexpr Bousk::uint16 HostPort = 8888;

14. class NetListener : public NetService::IListener

15. {

16. using OnIncomingConnectionCallback = std::function<bool(const Bousk::Network::Messages::IncomingConnection&)>;

17. using OnConnectionCallback = std::function<void(const Bousk::Network::Messages::Connection&)>;

18. using OnDisconnectionCallback = std::function<void(const Bousk::Network::Messages::Disconnection&)>;

19. using OnDataReceivedCallback = std::function<void(const Bousk::Network::Messages::UserData&)>;

20. public:

21. OnIncomingConnectionCallback mOnIncomingConnection;

22. OnConnectionCallback mOnConnectionResult;

23. OnDisconnectionCallback mOnDisconnection;

24. OnDataReceivedCallback mOnDataReceived;

26. private:

27. bool onIncomingConnection(const Bousk::Network::Messages::IncomingConnection& incomingConnection) override

28. {

29. return !mOnIncomingConnection || mOnIncomingConnection(incomingConnection);

30. }

32. void onConnectionResult(const Bousk::Network::Messages::Connection& connection) override

33. {

34. if (mOnConnectionResult)

35. mOnConnectionResult(connection);

36. }

38. void onDisconnection(const Bousk::Network::Messages::Disconnection& disconnection) override

39. {

40. if (mOnDisconnection)

41. mOnDisconnection(disconnection);

42. }

44. void onDataReceived(const Bousk::Network::Messages::UserData& userdata) override

45. {

46. if (mOnDataReceived)

47. mOnDataReceived(userdata);

48. }

49. };

51. int main_merged(const bool isNetworked, const bool isHost = false)

52. {

53. // Allouer le service dans la heap pour éviter l’avertissement de taille de la stack trop grande

54. std::unique_ptr<NetService> netService = std::make_unique<NetService>();

55. {

56. NetService::Parameters netServiceParameters;

57. netServiceParameters.networked = isNetworked;

58. netServiceParameters.host = isNetworked && isHost;

59. netServiceParameters.localPort = isHost ? HostPort : 0;

60. if (!isHost)

61. netServiceParameters.hostAddress = Bousk::Network::Address::Loopback(Bousk::Network::Address::Type::IPv4, HostPort);

62. if (!netService->init(netServiceParameters))

63. {

64. std::cout << "NetService initialization error : " << Bousk::Network::Errors::Get();

65. return -1;

66. }

67. }

69. SDL_Init(SDL_INIT_VIDEO);

71. SDL_Window* window = SDL_CreateWindow("TicTacToe", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, WIN_W, WIN_H, SDL_WINDOW_OPENGL);

73. constexpr std::string_view baseTitle = "TicTacToe - ";

74. auto updateWindowTitle = [&](const char* suffix)

75. {

76. std::string title(baseTitle);

77. if (netService->isNetworked())

78. {

79. if (netService->isHost())

80. title += "Hote";

81. else

82. title += "Client";

83. }

84. else

85. title += "Hors ligne";

86. title += " - ";

87. title += suffix;

88. SDL_SetWindowTitle(window, title.c_str());

89. };

90. SDL_Renderer* renderer = SDL_CreateRenderer(window, -1, SDL_RENDERER_ACCELERATED);

92. enum class State {

93. WaitingOpponent,

94. WaitingConnection,

95. MyTurn,

96. OpponentTurn,

97. Finished,

98. };

99. State state;

100. auto setState = [&](State newState)

101. {

102. state = newState;

103. switch (state)

104. {

105. case State::WaitingOpponent: updateWindowTitle("Attente de l’adversaire "); break;

106. case State::WaitingConnection: updateWindowTitle("Connexion en cours"); break;

107. case State::MyTurn: updateWindowTitle(netService->isNetworked() ? "Mon tour" : "Toueur du joueur 1"); break;

108. case State::OpponentTurn: updateWindowTitle(netService->isNetworked() ? "Tour de l’adversaire" : "Tour du joueur 2"); break;

109. case State::Finished: updateWindowTitle("Partie terminee"); break;

110. }

111. };

112. if (netService->isNetworked())

113. setState(netService->isHost() ? State::WaitingOpponent : State::WaitingConnection);

114. else

115. setState(State::MyTurn);

117. // Chargement des textures : Vides, X & O

118. std::array<SDL_Texture*, 3> plays{ LoadTexture("Empty.bmp", renderer), LoadTexture("X.bmp", renderer), LoadTexture("O.bmp", renderer) };

120. TicTacToe::Grid game;

121. // L’hôte joue X, le client O

122. const std::array<TicTacToe::Case, 2> players{ TicTacToe::Case::X, TicTacToe::Case::O };

123. // L’hôte joue premier

124. uint8_t currentPlayingPlayer = 0;

125. auto playCurrentTurnLocally = [&](unsigned int x, unsigned int y)

126. {

127. const TicTacToe::Case currentPlayerSymbol = players[currentPlayingPlayer];

128. if (game.play(x, y, currentPlayerSymbol))

129. {

130. // Si le coup est valide, passer au joueur suivant

131. currentPlayingPlayer = (currentPlayingPlayer + 1) % 2;

132. setState(state == State::OpponentTurn ? State::MyTurn : State::OpponentTurn);

133. return true;

134. }

135. return false;

136. };

138. Bousk::Network::Address opponent;

140. NetListener netListener;

141. netService->addListener(&netListener);

142. netListener.mOnIncomingConnection = [&](const Bousk::Network::Messages::IncomingConnection& msg)

143. {

144. if (netService->isHost() && state == State::WaitingOpponent)

145. {

146. setState(State::WaitingConnection);

147. return true;

148. }

149. return false;

150. };

151. netListener.mOnConnectionResult = [&](const Bousk::Network::Messages::Connection& msg)

152. {

153. if (state == State::WaitingConnection)

154. {

155. if (msg.result == Bousk::Network::Messages::Connection::Result::Success)

156. {

157. // Enregistrer l’adresse de l’adversaire

158. opponent = msg.emitter();

159. // L’hôte joue premier

160. setState(netService->isHost() ? State::MyTurn : State::OpponentTurn);

161. }

162. else if (netService->isHost())

163. {

164. // Retour à l’attente d’adversaire

165. setState(State::WaitingOpponent);

166. }

167. }

168. };

169. netListener.mOnDataReceived = [&](const Bousk::Network::Messages::UserData& msg)

170. {

171. Bousk::Serialization::Deserializer deserializer(msg.data.data(), msg.data.size());

172. TicTacToe::Net::Play play;

173. if (!play.read(deserializer))

174. {

175. std::cout << "Erreur critique : impossible de désérialiser le message de coup " << std::endl;

176. assert(false);

177. }

178. assert(state == State::OpponentTurn);

179. if (!playCurrentTurnLocally(play.x, play.y))

180. {

181. std::cout << "Erreur critique : impossible de jouer le coup " << std::endl;

182. assert(false);

183. }

184. };

185. netListener.mOnDisconnection = [&](const Bousk::Network::Messages::Disconnection& msg)

186. {

187. updateWindowTitle("Deconnecte");

188. };

189. while (1)

190. {

191. SDL_Event e;

192. if (SDL_PollEvent(&e))

193. {

194. if (e.type == SDL_QUIT)

195. {

196. break;

197. }

198. if (e.type == SDL_MOUSEBUTTONUP && (state == State::MyTurn || !netService->isNetworked()))

199. {

200. if (e.button.button == SDL_BUTTON_LEFT)

201. {

202. if (e.button.x >= 0 && e.button.x <= WIN_W

203. && e.button.y >= 0 && e.button.y <= WIN_H)

204. {

205. const unsigned int caseX = static_cast<unsigned int>(e.button.x / CASE_W);

206. const unsigned int caseY = static_cast<unsigned int>(e.button.y / CASE_H);

207. if (playCurrentTurnLocally(caseX, caseY))

208. {

209. if (netService->isNetworked())

210. {

211. TicTacToe::Net::Play msg;

212. msg.x = caseX;

213. msg.y = caseY;

214. Bousk::Serialization::Serializer serializer;

215. if (!msg.write(serializer))

216. {

217. std::cout << "Erreur critique : impossible de sérialiser le coup " << std::endl;

218. assert(false);

219. }

220. netService->sendTo(opponent, serializer.buffer(), serializer.bufferSize());

221. }

222. }

223. }

224. }

225. }

226. }

227. // Réception des données réseau

228. netService->receive();

229. // Dispatch des messages réseau

230. netService->process();

231. // Envoi des données en file d’envoi

232. netService->flush();

234. SDL_SetRenderDrawColor(renderer, 255, 255, 255, SDL_ALPHA_OPAQUE);

235. SDL_RenderClear(renderer);

236. // Affichage des cases

237. for (int x = 0; x < 3; ++x)

238. {

239. for (int y = 0; y < 3; ++y)

240. {

241. const TicTacToe::Case caseStatus = game.grid()[x][y];

242. const SDL_Rect position{ x * CASE_W, y * CASE_H, CASE_W, CASE_H };

243. SDL_RenderCopy(renderer, plays[static_cast<unsigned int>(caseStatus)], NULL, &position);

244. }

245. }

246. // Affichage de la grille

247. SDL_SetRenderDrawColor(renderer, 0, 0, 0, SDL_ALPHA_OPAQUE);

248. // Horizontale

249. SDL_RenderDrawLine(renderer, 0, CASE_H, WIN_W, CASE_H);

250. SDL_RenderDrawLine(renderer, 0, CASE_H * 2, WIN_W, CASE_H * 2);

251. // Verticale

252. SDL_RenderDrawLine(renderer, CASE_W, 0, CASE_W, WIN_H);

253. SDL_RenderDrawLine(renderer, CASE_W * 2, 0, CASE_W * 2, WIN_H);

255. if (game.isFinished())

256. {

257. const TicTacToe::Case winner = game.winner();

258. if (winner != players[0] && winner != players[1])

259. updateWindowTitle("Match nul");

260. else

261. {

262. if (netService->isNetworked())

263. {

264. updateWindowTitle((winner == players[0]) == netService->isHost() ? "Victoire " : "Defaite");

265. }

266. else

267. {

268. updateWindowTitle(winner == players[0] ? "Victoire du joueur 1" : "Victoire du joueur 2");

269. }

270. }

271. }

273. SDL_RenderPresent(renderer);

274. SDL_Delay(1);

275. }

277. for (SDL_Texture* texture : plays)

278. {

279. SDL_DestroyTexture(texture);

280. }

281. SDL_DestroyRenderer(renderer);

282. SDL_DestroyWindow(window);

283. SDL_Quit();

284. return 0;

285. }

Retrouvez le code source dans le dépôt GitHub dédié.

Article précédent

<< UDP – Gérer des connexions entre machines

UDP - Debugger une application en réseau >>

VIII -