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ème 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 :
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-A. 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() :
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-B. 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.
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.
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-C. 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 :
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 connexion (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 :
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-D. 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.
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
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éseau 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. �? 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 :
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-E. 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.
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 :
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-F. Recherche de parties disponibles▲
Chaque client souhaitant participer à une partie doit d'abord contacter le serveur qui héberge la partie. Pour cela, 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.
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é :
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 trois secondes, on vide le conteneur dédié pour afficher toutes les parties repérées au client :
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-G. 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. �?a 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.