Architecture d'un jeu vidéo 3D


précédentsommairesuivant

V. Le moteur de réseau

Le moteur de réseau est un gros morceau de l'architecture, il doit permettre à plusieurs machines de communiquer, que ça soit sur un réseau local ou bien sur internet. J'ai choisi d'utiliser l'organisation suivante :

  • Sur un réseau LAN, les paquets importants sont envoyés via TCP entre le serveur et les clients. Les paquets de moindre importance sont envoyés en UDPmulticast.
  • Sur un réseau de plus grande ampleur comme internet, tous les messages sont envoyés entre le serveur et un client. J'utilise toujours deux connexions (TCP et UDP) selon que les messages à envoyer peuvent être égarés ou non.

Il va nous falloir distinguer dès le début la notion de machine réseau de la notion de joueur réseau. En effet, plusieurs joueurs réseau peuvent jouer sur une même machine réseau. C'est notamment le cas des personnages non joueurs qui peuvent être gérés par des clients. Cette distinction nous permettra de répartir la gestion des IA entre les différentes machines, pour utiliser la puissance des machines les plus performantes. J'ai choisi de déterminer la performance des machines réseau en regardant le nombre d'images qu'elles sont capables d'afficher par seconde.

Chaque machine possèdera une liste des autres machines auxquelles elle est reliée. Elle possèdera aussi les objets systèmes pour communiquer avec ces machines.

Lors d'un jeu en LAN, chaque joueur peut directement informer tout le réseau via la connexion multicast. Dans le cas d'un jeu sur internet, chaque machine souhaitant communiquer avec tous les joueurs devra envoyer le message au serveur, qui relayera ce message pour toutes les machines connectées.

Voici la classe utilisée pour représenter une machine distante :

 
Sélectionnez
class network_machine{
public:
	network_machine();
	network_machine(network_engine*, asio::ip::tcp::socket *);
	~network_machine();
	void attach_player(network_player*);
	void remove_player(network_player*);

	void TCP_async_receive(const asio::error_code&, size_t);
	void TCP_send(engine_event&);
	void UDP_send(engine_event&);

	asio::ip::tcp::socket *s_tcp;
	asio::ip::udp::socket *s_udp;
	
	// ...

private:
	// liste des joueurs associés à la machine réseau
	std::list<network_player*> l_players;
	std::vector<char> network_buffer;
	
	// ...
	
	network_engine *parent;
};

Les machines réseau apparaissent comme des membres du moteur de réseau. Chaque machine réseau peut être atteinte via TCP ou UDP. Par contre, la réception des données venant de cette machine distante est légèrement différente : la réception TCP se fait simplement dans la machine réseau alors que la réception UDP se fait par la socket en écoute du moteur réseau.

V-1. La sérialisation des messages

J'ai choisi de m'appuyer sur le système de sérialisation de boost pour envoyer les messages des moteurs sur le réseau. Ce système permet de convertir n'importe quel objet sérialisable en une structure linéaire d'octets (entre autres) facile à envoyer sur une socket. De la même manière, le système est capable de reconstituer l'objet de départ.

Il nous faut donc déclarer notre classe de messages comme sérialisable, pour cela il nous suffit d'y implémenter une fonction serialize() :

 
Sélectionnez
template<class Archive>
void serialize(Archive& ar, const unsigned int){
	ar & type;
	ar & s_data;
	ar & i_data;
	ar & f_data;
	ar & v_data;
}

La sérialisation d'un engine_event va automatiquement appeler la sérialisation des objets contenus, et donc des objets serializable_vector3df définis plus haut. Maintenant que nos messages sont sérialisables, voyons comment ils sont envoyés sur le réseau.

V-2. Envoi / réception des messages

Pour envoyer un message sur une socket, il faut créer le buffer qui va stocker notre message sérialisé. La sérialisation proprement dite est réalisée par l'opérateur <<. Voici le code de la fonction d'envoi de message sur la socket TCP de la machine réseau.

 
Sélectionnez
void network_machine::TCP_send(engine_event& e){
	std::ostringstream archive_stream;
	boost::archive::text_oarchive archive(archive_stream);
	archive << e;
	const std::string &outbound_data = archive_stream.str();

	s_tcp->send(asio::buffer(outbound_data));
}

La fonction de réception est exactement la duale. J'ai choisi d'utiliser une réception asynchrone, ainsi la fonction TCP_async_receive est appelée à chaque réception.

 
Sélectionnez
void network_machine::TCP_async_receive(const asio::error_code& e, size_t bytes_received){
	// test sur la réception
	// ...
	
	// désérialisation
	std::string str_data(&network_buffer[0], network_buffer.size());
	std::istringstream archive_stream(str_data);
	boost::archive::text_iarchive archive(archive_stream);

	engine_event ne;
	archive >> ne;

	// on ajoute le message sur la pile des messages à traiter
	parent->push_received_event(ne);
}

V-3. Les sockets pour atteindre une machine réseau

De manière à utiliser les appels asynchrones pour la réception en mode TCP, il nous faut le spécifier lors de la création de la socket :

 
Sélectionnez
network_machine::network_machine(network_engine* p, asio::ip::tcp::socket *so, int i):s_tcp(so), parent(p), id(i){
	// création de la socket UDP
	udp_ep = new asio::ip::udp::endpoint( so->remote_endpoint().address(), parent->port_udp_reception);
	s_udp = new asio::ip::udp::socket(io, *udp_ep);

	address = so->remote_endpoint().address().to_string();

	// mise en place de l'appel asynchrone
	so->async_receive(		asio::buffer(network_buffer),
							boost::bind(&network_machine::TCP_async_receive, this,
							asio::placeholders::error,
							asio::placeholders::bytes_transferred)
						);

	asio::thread(boost::bind(&asio::io_service::run, &io));
}

Et de la même manière, le destructeur de la network_machine se contentera de détruire les sockets créées ainsi que tous les joueurs qui y sont éventuellement rattachés.

Une machine réseau doit être créée pour communiquer avec tous les acteurs du réseau, quel que soit le mode de connection (LAN, internet). Quand un client souhaite se connecter directement à un serveur, la création de la machine réseau est légèrement différente :

 
Sélectionnez
void network_engine::client_connects_to_server(std::string& host, std::string& nick, std::string& pass){
	network_machine *serveur = new network_machine(this, get_next_machine_id());
	l_machines.push_back(serveur);	

	if (serveur->connect(host, nick, pass) == 1){
		// la connexion a échoué, on supprime la machine réseau fraichement créée
		std::vector<network_machine*>::iterator i = l_machines.end();
		i--;
		l_machines.erase(i);
		get_graphics_engine()->get_gui()->ShowErrorMessage(std::string("Impossible de se connecter à ")+host, gui_system::INTERNET_MENU);
	}else
		// on affiche l'écran suivant
		gfxe->get_gui()->display_select_players();
}

La fonction connect(host, nick, pass) va essayer d'établir une connexion TCP sur l'adresse donnée, puis va commencer le protocole en envoyant le nom choisi et un mot de passe pour la partie. En cas d'échec (adresse injoignable ?), on affiche un message d'erreur.

V-4. Spécification réseau local ou réseau Internet

Les traitements à effectuer pour propager une information sur le réseau en LAN et sur Internet ne seront pas tout à fait les mêmes. J'ai créé une classe network_interface qui se spécialise en LAN_network_interface et en Internet_network_interface selon les choix réalisés par le client dans l'interface graphique.

 
Sélectionnez
class network_interface{
public:
	network_interface(network_engine* n):parent(n){};

	// fonctions de l'observateur
	void send_add(int, serializable_vector3df&, serializable_vector3df&);
	void send_move(int, serializable_vector3df&, serializable_vector3df&);
	void send_rem(int);

	// utilisé en mode client et serveur
	virtual void send_to_all_gameUDP(engine_event &)=0;

	// utilisé seulement en mode client
	void send_to_serverTCP(engine_event &);

	// utilisé seulement en mode serveur
	void send_to_all_gameTCP(engine_event &);

	// pour repérer quel mode a été instancié
	enum{
		LAN,
		INTERNET,
	};

	inline int get_type(){return type;}

protected:
	network_engine *parent; 
	int type;
};

avec

 
Sélectionnez
class LAN_network_interface : public network_interface{
public:
	LAN_network_interface(network_engine*);
	~LAN_network_interface();

	void send_to_all_gameUDP(engine_event &);
};

La définition de la classe Internet_network_interface est similaire. Seule la fonction send_to_all_gameUDP va changer : on utilisera le mode multicast en LAN et on bouclera sur toutes les machines réseaux en mode internet.

Un point important concerne l'aspect observateur développé dans la classe d'interface réseau. En effet, l'interface réseau doit retranscrire chaque modification du graphe de scène à toutes les machines connectées. Ces fonctions d'observateur sont appelées à chaque fois que le graphe de scène évolue. Chacune de ces fonctions va déclencher l'envoi d'un message en UDP ou en TCP selon l'importance du message. Ainsi la création et la destruction d'objets du graphe de scène sont envoyées en TCP. Ces messages seront beaucoup plus rares que les messages de déplacement, qui sont eux, envoyés par UDP.

Une fois les fonctions de base du moteur de réseau écrites, il reste à organiser leurs appels : il va falloir écrire un système permettant d'instancier les bons objets en fonction des choix de l'utilisateur, notamment en suivant les clics qu'il fait dans l'interface graphique. Ces choix nous permettront d'instancier la bonne classe.

Un serveur aura toujours une socket TCP en écoute, pour repérer les éventuelles machines qui souhaitent se connecter et participer au jeu. A chaque nouvelle machine, le serveur va créer un thread et ajouter la machine dans sa liste.

Un client ne sera jamais relié qu'à une seule machine : le serveur, et tout se fera exactement comme si tous les autres joueurs jouaient physiquement sur le serveur puisque celui-ci relayera tous les messages.

Voici deux schémas qui valent mieux qu'un long discours :

Organisation du réseau en mode Internet
Organisation du réseau en mode Internet
Organisation du réseau en mode LAN
Organisation du réseau en mode LAN

Les sockets TCP sont à double emploi : elles nous permettent de transmettre des messages importants et aussi elles nous permettent de repérer les déconnexions éventuelles des joueurs.

V-5. Réception des messages UDP

Tous les messages TCP arrivent dans leurs machines réseaux respectives, par contre, les messages UDP arrivent tous sur la même socket, qui est la socket générale d'écoute UDP du moteur réseau. En effet, en créant autant de sockets UDP d'écoute que de machines réseau, il aurait fallu ouvrir autant de ports et donc potentiellement rerouter autant de ports sur des éventuels routeurs.

UDP étant un mode non connecté, nous pouvons économiser des sockets. Par contre, et c'est l'effet pervers du mode non connecté, tout le monde peut envoyer des messages sur cette socket. Il suffirait de connaître le numéro du port pour pouvoir accéder à notre application. C'est pourquoi, j'ai choisi de n'accepter aucun message UDP qui ne vienne pas d'une machine réseau déjà enregistrée. En effet, il n'y aura d'échanges UDP qu'une fois que la machine réseau sera enregistrée en TCP, nous pouvons donc créer une table des adresses IP connectées. Chaque machine réseau possèdera connaîtra son adresse IP, il suffira de parcourir les machines connectées et de comparer leur IP pour savoir si on peut accepter les messages d'une IP donnée.

Mise en place de l'écoute UDP générale
Sélectionnez
	asio::ip::udp::endpoint listen_ep(asio::ip::udp::v4(), port_udp_reception);
	s_udp_in = new asio::ip::udp::socket (io, listen_ep.protocol());

	// mise en place de l'appel asynchrone
	s_udp_in->async_receive_from(	asio::buffer(network_buffer),
					udp_remote_endpoint,
					boost::bind(&network_engine::UDP_async_read, this,
					asio::placeholders::error,
					asio::placeholders::bytes_transferred)
	);

Chaque appel va stocker l'adresse et le port qui ont émis le message dans udp_remote_endpoint. Il nous suffira de le tester dans l'appel asynchrone :

 
Sélectionnez
void network_engine::UDP_async_read(const asio::error_code& e , size_t bytes_receives){
	// on vérifie si l'adresse est enregistrée
	if (!is_address_registered(udp_remote_endpoint.address().to_string()))
		return;
	
	// on désérialise le message
	std::string str_data(&network_buffer[0], network_buffer.size());
	std::istringstream archive_stream(str_data);
	boost::archive::text_iarchive archive(archive_stream);

	engine_event ne;
	archive >> ne;

	// on ajoute un identifiant pour repérer l'expéditeur
	ne.i_data["FROM"] = get_machine_from_address(udp_remote_endpoint.address().to_string())->id;

	// on ajoute le message sur la pile des messages à traiter
	push_received_event(ne);
}

La fonction is_address_registered se contente de parcourir les machines déjà enregistrées pour en chercher une qui possède l'adresse à tester.

On remarque l'ajout d'un identifiant pour l'expéditeur : cet identifiant est généré à chaque fois qu'une nouvelle machine va se connecter. Cet identifiant ne doit jamais circuler sur le réseau, il est propre à chaque exécution du jeu. En effet, un client ne sera relié qu'à un serveur alors que le serveur sera relié à plusieurs clients, l'identifiant "1" peut donc exister plusieurs fois mais avec des correspondances différentes.

Chaque machine physique possède une socket UDP en écoute sur un port connu à l'avance. Il est donc délicat de masquer plusieurs machines derrière un sous réseau. Leur adresse publique est identique. Pour pallier ce manque, on pourrait mettre en place un système où chaque machine physique cherche elle-même un port libre puis le communique au serveur. Mais ce port pourrait changer à chaque exécution et les routages NAT devraient être régulièrement vérifiés.

V-6. Recherche de parties disponibles

Chaque client souhaitant participer à une partie doit d'abord contacter le serveur qui héberge la partie. Pour celà, il doit en connaître l'adresse. Sur Internet, nous avons deux possibilités : chaque serveur proposant une partie s'enregistre auprès d'une entité tierce, accessible par tout le monde et dont l'adresse est connue ; ou bien chaque client doit connaître l'adresse du serveur (adresse transmise par email, ou par tout autre moyen). En réseau local, nous pouvons nous appuyer sur les adresses multicast : si tout le monde écoute cette même adresse, les clients pourront être informés de toutes les parties créées sur le réseau local.

Envoi de la proposition de partie, par le serveur
Sélectionnez
void network_engine::thread_send_multicast(engine_event gp){
	boost::mutex::scoped_lock l(state_mutex);
		int current_state = state;
	l.unlock();
	
	asio::deadline_timer timer(io, boost::posix_time::seconds(2));

	// on envoie la proposition tant que le jeu n'a pas démarré
	while (current_state == STATE_WAITING_PLAYERS){
		send_eventUDP(gp, s_multicast);

		// on envoie le message toutes les 2 secondes
		timer.expires_at(timer.expires_at() + boost::posix_time::seconds(2));
		timer.wait();

		l.lock();
			current_state = state;
		l.unlock();
	}
}

Chaque machine écoute l'adresse multicast et stocke les propositions de parties dans un conteneur dédié :

 
Sélectionnez
void network_engine::multicast_receive(const asio::error_code&, std::size_t){
	// désérialisation du message
	std::string str_data(&network_buffer[0], network_buffer.size());
	std::istringstream archive_stream(str_data);
	boost::archive::text_iarchive archive(archive_stream);

	engine_event e;
	archive >> e;

	switch(state){
		case engine::STATE_WAITING_PLAYERS:
			if (e.type == engine_event::GAME_PROP){
				// add the game proposition to the waiting list
				boost::mutex::scoped_lock l(propositions_mutex);
					
					// on regarde si le message a déjà été reçu dans les 3 dernières secondes
					std::vector<engine_event>::iterator i = std::find(	
							received_propositions.begin(), 
							received_propositions.end(),
							e);
					if (i != received_propositions.end())
						received_propositions.push_back(e);

				l.unlock();
				break;
			}			
			break;

		default:
			// ne doit jamais arriver
			break;
	}
}

Chaque 3 secondes, on vide le conteneur dédié pour afficher toutes les parties repérées au client :

 
Sélectionnez
void network_engine::listen_multi_3seconds_udp(){	
	boost::mutex::scoped_lock state_lock(state_mutex);
		state = STATE_WAITING_PLAYERS;
	state_lock.unlock();

	asio::deadline_timer timer(io, boost::posix_time::seconds(3));
	int current_state = state;

	while (current_state == STATE_WAITING_PLAYERS){

		std::vector<engine_event> prop;
			
		timer.expires_at(timer.expires_at() + boost::posix_time::seconds(3));
		timer.wait();

		// on récupère toutes les parties repérées
		boost::mutex::scoped_lock l(propositions_mutex);
			prop = received_propositions;
			received_propositions.clear();
		l.unlock();

		boost::mutex::scoped_lock state_lock(state_mutex);
			current_state = STATE_WAITING_PLAYERS;
		state_lock.unlock();
		
		// affichage des parties dans l'interface graphique
		// ...
	}
}

V-7. Propagation des messages

La propagation des messages ne se fera pas de la même manière en mode client et en mode serveur : un serveur doit transférer les messages qu'il reçoit aux autres machines connectées. Ca peut paraître bête à dire, mais le serveur ne doit pas retransmettre un message à la machine réseau qui vient de lui envoyer. On va donc devoir tenir compte des expéditeurs des messages en mode serveur.

Le mode client est moins problématique : on se contente de mettre à jour les données locales en fonction des messages reçus, rien n'est transmis.

Dans tous les cas, client ou serveur, LAN ou Internet, tous les graphes de scène doivent être identiques, sur toutes les machines du réseau. Sans ça, la cohérence entre les joueurs ne peut être assurée.


précédentsommairesuivant

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Copyright © 2007 Pierre Schwartz. 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.