Architecture d'un jeu vidéo 3D


précédentsommairesuivant

II. Les différents modules

Voici la classe de base de notre architecture, celle qui sera instanciée pour démarrer le jeu.

classe de base pour notre architecture
Sélectionnez
class game{
public:
	game();
	~game();
	// ...

private:
	std::list<engine*> l_modules;
};

Il va nous falloir une classe générique pour représenter un moteur du jeu :

classe générique pour un moteur
Sélectionnez
class engine{
public:
	engine(game*);
	virtual ~engine();

	// accepter les messages des autres moteurs
	void push_event(engine_event& e){
		events_queue.push(e);
	}

	// traite la file des messages
	void process_queue(){
		while (! events_queue.empty()){
			engine_event e = events_queue.front();
			events_queue.pop();
		
			process_event(e);
		}
	}

	// traitement propre à chaque moteur
	virtual void frame() = 0;
protected:
	// pointeur vers l'objet contenant
	game *parent;
	
	// file des messages à traiter
	std::queue<engine_event> events_queue;
	
	// traitement d'un message, propre à chaque moteur
	virtual void process_event(engine_event&) = 0;
};

Je détaillerai plus loin de principe des objets engine_event, messages envoyés d'un module à l'autre. La classe engine possède des fonctions virtuelles pures, elle ne peut donc pas être instanciée. Néanmoins, on remarque que pour pouvoir communiquer avec un autre module, il va falloir remonter à l'objet contenant pour récupérer un pointeur sur le module à atteindre, éventuellement downcaster le pointeur pour pouvoir accéder aux fonctions propres à ce module et ensuite spécifier les opérations à effectuer.

Les files de messages peuvent potentiellement être accédées par des traitements parallèles, chaque moteur doit donc posséder un mutex à verrouiller à chaque ajout de message et à chaque vidage de la liste.

J'ai choisi de mettre les modules suivants :

  • le moteur de jeu, pour gérer les différents joueurs et toutes les données propres au jeu
  • le moteur graphique, chargé de représenter la vision du jeu pour un joueur (le joueur ayant physiquement démarré le jeu
  • le moteur audio, chargé de représenter l'aspect audio du jeu pour un joueur particulier
  • le moteur de réseau, chargé d'échanger les données avec les autres joueurs

Le constructeur de la classe engine sert principalement à renseigner le conteneur parent pour chaque module. Ce constructeur devra être appelé à partir des constructeurs des classes filles.

L'exécution proprement dite du jeu consistera simplement en des appels continus à toutes les fonctions frame() des différents moteurs. La condition d'arrêt de cette boucle quasi infinie devra être modifiée par le moteur de jeu. L'architecture possède donc un booléen représentant l'arrêt du programme. Ce booléen devra être protégé par des verrous pour se garantir des accès concurrents éventuels.

 
Sélectionnez
class game{
	// ...
	boost::mutex still_running_mutex;
	bool still_running;
	// ...
};
 
Sélectionnez
void game::run(){
	bool current_still_running = still_running;
	
	// on crée un verrou pour accéder au mutex
	boost::mutex::scoped_lock l(still_running_mutex);
	l.unlock();	
	
	while (current_still_running){
		n->frame();
		g->frame();
		gfx->frame();
		s->frame();
		l.lock();
			current_still_running = still_running;
		l.unlock();
	}
}

Ainsi, pour terminer le jeu, il suffira d'appeler un modificateur du booléen still_running.

 
Sélectionnez
void game::stooooop(){
	boost::mutex::scoped_lock l(still_running_mutex);
	still_running = false;
}

La libération du mutex est faite automatiquement à la fin de la portée du verrou. Le destructeur de boost::mutex::scoped_lock appelle la fonction unlock(), il n'est donc pas nécessaire de le spécifier explicitement.

Le destructeur de la classe game va appeler les destructeurs de tous les modules instanciés.

 
Sélectionnez
game::~game(){
	delete n;
	delete g;
	delete gfx;
	delete s;
}

II-1. Communication entre les modules

La communication entre les modules étant vraiment à la base de notre architecture, de très nombreux appels seront effectués pour faire communiquer nos modules, cet aspect doit donc être le plus rapide possible. On pourrait mettre en place un système d'accesseur dans l'architecture pour renvoyer les pointeurs demandés, mais ça restera beaucoup plus lourd que des accès directs. C'est pourquoi j'ai choisi de donner à chaque moteur des pointeurs 'en dur' vers les autres modules. Cela nécessite de connaître à l'avance le nombre de modules à mettre en jeu.

Chaque moteur comporte ainsi 4 pointeurs vers les moteurs de jeu, graphique, audio et réseau. De même, chaque module possède une fonction permettant d'atteindre directement les files de messages des autres modules. Pour cela, il est nécessaire de lier tous les modules entre entre eux lors de leur création.

 
Sélectionnez
class game{
// ...
	game_engine *g;
	graphics_engine *gfx;
	network_engine *n;
	sound_engine *s;
};
 
Sélectionnez
class engine{
// ...	
	void send_message_to_graphics(engine_event&);
	void send_message_to_network(engine_event&);
	void send_message_to_game(engine_event&);
	void send_message_to_sound(engine_event&);

	game_engine *ge;
	network_engine *ne;
	graphics_engine *gfxe;
	sound_engine *se;
// ...
};

Voici le code du constructeur de l'architecture, qui va lier tous les modules entre eux :

 
Sélectionnez
game::game(){
	n = new network_engine(this);
	g = new game_engine(this);
	gfx = new graphics_engine(this);
	s = new sound_engine(this);

	// lier tous les modules ensemble
	n->attach_game_engine(g);
	n->attach_graphics_engine(gfx);
	n->attach_sound_engine(s);
	
	g->attach_graphics_engine(gfx);
	g->attach_network_engine(n);
	g->attach_sound_engine(s);

	gfx->attach_game_engine(g);
	gfx->attach_network_engine(n);
	gfx->attach_sound_engine(s);

	s->attach_game_engine(g);
	s->attach_graphics_engine(gfx);
	s->attach_network_engine(n);
}

Les différentes fonctions attach_XXX se contentent de recopier les adresses fournies dans les pointeurs vers les autres modules. Une fois l'architecture créée, tous les modules sont capables de communiquer entre eux, c'est déjà une bonne chose de faite.

Les modules ont donc deux moyens pour communiquer entre eux : s'envoyer un message au travers des files de messages ou bien appeler directement les fonctions des autres modules. L'envoi de messages a l'avantage qu'il renforce la séparation des modules, par contre, pour chaque message qu'on peut envoyer d'un module à l'autre, le module récepteur devra être capable de le lire et de l'interprêter. L'envoi de messages est légèrement plus coûteux puisqu'il va demander l'empilage du message sur la file du récepteur ainsi que l'analyse du message lors de sa réception. Il ne faudra donc pas trop abuser des envois de messages entre les modules pour privilégier les appels directs.

II-1-A. Les messages

Il nous faut une classe pouvant représenter tous les types de messages qui peuvent être échangés entre nos modules. Et pour faire encore plus générique, il faudrait que cette classe puisse aussi être utilisée pour communiquer entre les différentes machines du réseau. Les différents modules et les différents machines peuvent s'envoyer des informations de diverses natures : des chaines de caractères, des simples nombres, des vecteurs 3D représentant des positions ...

J'ai choisi d'utiliser une classe comprenant des std::map pour différents types de données :

 
Sélectionnez
class engine_event{
public:
	int type;
	std::map<std::string, std::string> s_data;
	std::map<std::string, int> i_data;
	std::map<std::string, float> f_data;
	std::map<std::string, serializable_vector3df> v_data;
	bool operator==(const engine_event& e);
	template<class Archive>
	void serialize(Archive& ar, const unsigned int);
};

serializable_vector3df représente simplement une classe fille de la classe de vecteur 3D de flottants d'Irrlicht. J'ai spécialisé cette classe pour la rendre sérialisable, donc pour pouvoir l'envoyer simplement sur le réseau en utilisant boost::serialize :

classe de vecteurs sérialisable
Sélectionnez
class serializable_vector3df : public irr::core::vector3df{
public:
	serializable_vector3df(){}
	serializable_vector3df(irr::core::vector3df& v):irr::core::vector3df(v){}
	template<class Archive>
	void serialize(Archive& ar, const unsigned int){
		ar & X;
		ar & Y;
		ar & Z;
	}	
};

L'attribut engine_event::type permettra de repérer le type de message, de manière à orienter la recherche des informations dans les std::map.

Il faudra penser à utiliser des clefs de std::map aussi courtes que possibles : en effet, ces clefs seront elles aussi sérialisées et envoyées. Quitte même à les troquer contre des int ou d'autres types plus légers.

Suivant les informations que vous choisirez d'envoyer par l'intermédiaire de ces engine_events, vous n'utiliserez pas nécessairement les 4 std::maps proposées, vous pourrez donc sans problème en supprimer l'une ou l'autre. Les messages s'en trouveront plus légers et plus rapides à échanger, notamment sur le réseau.

II-2. Ajout d'un module

Dans le cas de l'utilisation d'une liste de modules std::list<engine*> il est très simple d'ajouter un module, il suffit d'ajouter un item à cette liste. Par contre dans notre cas, où les modules accèdent directement les uns aux autres, il va falloir rajouter un pointeur membre dans la classe game ainsi que dans la classe engine. De même, il va falloir attacher ce nouveau module à tous ceux déjà existants.

II-3. Utilisation des modules en mode passif

Toujours dans un souci de réutilisation, certains modules doivent pouvoir être démarrés en mode passif. Par exemple, il pourra être intéressant de ne pas faire d'affichage dans le moteur graphique dans le cas d'un serveur de jeu. On gagnera en performances. En effet, le moteur graphique et le moteur audio ne sont que la partie visible du jeu pour un joueur donné. L'idée est de pouvoir lancer le jeu en mode démon, pour qu'il puisse se concentrer sur la gestion du jeu et des autres joueurs.

Pour pouvoir lancer le jeu dans un tel mode, il va falloir paramétrer l'exécution : en passant des paramètres par la ligne de commandes.

J'ai donc créé une petite classe permettant d'analyser les paramètres de la ligne de commandes. Cette classe n'est pas primordiale, elle a plus un rôle d'accessoire qu'autre chose, mais elle est bien pratique tout de même.

 
Sélectionnez
class parameter_analyser{
public:
	parameter_analyser();
	parameter_analyser(int, char**);	// argc, argv
	std::map<std::string, bool> data;
};

Le constructeur se contente d'analyser tous les paramètres de la ligne de commandes et de remplir la std::map à true lorsque le paramètre a été spécifié. Si les paramètres ne sont pas spécifiés dans la ligne de commandes, les valeurs de data sont mises à false. Par exemple, une exécution avec l'option --daemon ou -d mettra la valeur associée à la clef "daemon" à true.

Il suffit ensuite de passer une référence sur l'instance de parameter_analyser au constructeur game() qui analysera les données pour appeler un constructeur de module ou un autre. Ainsi, on peut créer un constructeur de moteur graphique qui accepte un booléen, suivant qu'il faut ou non créer un contexte graphique. Idem pour le moteur audio.

Le jeu démarré en mode passif doit être contrôlé différemment. En effet, vous ne pouvez pas interagir avec la souris ni le clavier, et c'est encore plus vrai si vous associez au mode passif la création d'un nouveau processus de manière à rendre la main sitôt après le démarrage du jeu. C'est la raison pour laquelle il va falloir mettre en place un système de console connaissant quelques commandes de base.

Prenons le cas d'un serveur de jeu, il faut pouvoir communiquer avec le serveur via le réseau. Notre console va donc presque naturellement écouter le réseau, accepter les connexions sur un port particulier, éventuellement faire une authentification par login / mot de passe puis va être en écoute des commandes. Pour sécuriser encore la transmission, on pourrait passer par une couche SSL.

 
Sélectionnez
class console{
public:
	console();
	~console();
	void process_command(std::string&);

	// fonction à lancer pour chaque client
	void server_thread_tcp_receive(asio::ip::tcp::socket *);
	// ...
private:
	// attributs propres à la gestion réseau : sockets, contexte réseau ...
};

La réalisation d'une telle console revient ni plus ni moins à implémenter un serveur multiclients. Et pour les mêmes raisons que dans mon précédent article sur une architecture de serveur multithreads, j'ai choisi d'utiliser un contexte multithreadé. Nous pouvons ainsi simplement gérer plusieurs connexions simultanées. Pour des raisons de simplicité et surtout de robustesse, j'ai choisi d'utiliser la bibliothèque asio pour la gestion du réseau.

II-3-A. Implémentation du système de console

Il va falloir rattacher la console quelque part dans notre architecture. J'ai choisi de la placer en attribut du moteur de jeu. Elle sera donc créée lors de son instanciation.

La déclaration complète de la console
Sélectionnez
class console{
public:
	console(game_engine*);
	~console();
	void process_command(CEGUI::String&);
	void process_command(std::string&);

	void handle_accept_tcp(const asio::error_code&, asio::ip::tcp::socket*);
	void server_thread_tcp_receive(asio::ip::tcp::socket *);
private:
	game_engine *parent;
	asio::ip::tcp::socket *s;
	asio::ip::tcp::acceptor *tcp_acceptor;
	asio::io_service io;
};
Le constructeur
Sélectionnez
console::console(game_engine* g):parent(g){
	// création de la socket d'écoute
	s = new asio::ip::tcp::socket(io);
	tcp_acceptor = new asio::ip::tcp::acceptor(io, asio::ip::tcp::endpoint(asio::ip::tcp::v4(), 12345));

	asio::error_code e;
	tcp_acceptor->async_accept(*s, boost::bind(&console::handle_accept_tcp, this, e, s));	

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

	if (e.value() != 0){
		std::cerr << e.message() << std::endl;
	}
}

Le constructeur crée une socket qui va écouter les connexions TCP sur le port 12345. A chaque connexion entrante, il va appeler console::handle_accept_tcp. Cet appel est effectué en mode asynchrone. Chaque appel à handle_accept_tcp va démarrer un thread pour s'occuper de cette connexion :

 
Sélectionnez
void console::handle_accept_tcp(const asio::error_code& e, asio::ip::tcp::socket* socket){
	if (e.value() != 0){
		std::cerr << e.message() << std::endl;
		return;
	}

	// on démarre le thread du client
	asio::thread t(boost::bind(&console::server_thread_tcp_receive, this, socket));

	// on réarme l'appel asynchrone avec une nouvelle socket
	asio::ip::tcp::socket *s = new asio::ip::tcp::socket(io);
	asio::error_code ec;
	tcp_acceptor->async_accept(*s, boost::bind(&console::handle_accept_tcp, this, ec, s));
}

Il ne nous reste plus qu'à écouter les instructions envoyées par le client :

 
Sélectionnez
void console::server_thread_tcp_receive(asio::ip::tcp::socket *s){
	// authentification éventuelle

	// on attend les instructions de manière bloquante
	for (;;){
		boost::array<char, 1024> buf;
		asio::error_code error;

		size_t len = s->read_some(asio::buffer(buf), error);

		if (error == asio::error::eof)
			break; // la connexion a été interrompue
		else if (error)
			break;

		process_command(std::string(buf.data()));
	}
	s->close();
	delete s;
}

Le protocole est extrèmement simple : le client envoie des instructions et le serveur lui renvoie le résultat de la réception, tout ça sous forme de chaînes de caractères.

Et à chaque réception d'une instruction, on appelle le traitement de cette instruction. C'est ici qu'on doit implémenter au moins la commande de fermeture.

 
Sélectionnez
void console::process_command(std::string& c){
	if (c == "quit")
		parent->parent->stooooop();	
		
	// autres instructions existantes
}

II-3-B. Un client pour la console

J'ai écrit un client basique pour pouvoir accéder à la console à distance sur un tel serveur de jeu. Il s'agit simplement d'une fenêtre de saisie de commande :

Image non disponible

Le code source de ce client est également disponible dans l'archive en téléchargement à la fin de cet article.


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.