Programmer un jeu est un excellent exercice pour apprendre un langage informatique, progresser en algorithmique et manipuler des interfaces graphiques. Pour peu que l’on ait une bon concept multijoueur, on peut avoir envie de faire en sorte que plusieurs personnes interagissent depuis des ordinateurs différents. Je ne savais pas du tout comment faire, mais j’ai trouvé de la documentation dans le guide pratique des sockets en Python de Python.org et dans l’article Les sockets du Python du site pour l’enseignement de l’informatique du lycée Blaise Pascal de Clermont-Ferrand.
Structure serveur et client
Script du serveur
Avec un ordinateur sur lequel Python est installé, on peut écrire un fichier serveur.py
comme ci-dessous :
import socket # chargement de la bibliothèque
# création de l’interface de connexion (ou connecteur)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as connecteur_ouvert:
connecteur_ouvert.bind(("", 5999)) # définition des paramètres du connecteur
connecteur_ouvert.settimeout(600) # délai d’attente par défaut en secondes
connecteur_ouvert.listen() # démarrage du connecteur
# récupération de la connexion d’un client avec son adresse IP
connexion, adresse = connecteur_ouvert.accept()
# présentation du client
print("Connexion de", adresse[0], "sur le port", adresse[1])
with connexion:
connexion.send(b"Comment vous appelez-vous ?") # envoi d’un message au client
recu = connexion.recv(1024) # récupération de la réponse du client
nom = recu.decode() # décodage de la réponse
# encodage du retour au client
connexion.send(bytes(f"Bonjour {nom} !", "utf-8"))
Le connecteur a pour seule fonction d’obtenir une connexion avec un client. C’est cette connexion qui assure ensuite l’échange des données entre le serveur et le client. La structure with
permet de fermer automatiquement le connecteur à la fin du programme, même en cas d’erreur dans l’exécution du bloc.
Les paramètres du connecteurs sont une adresse IP et un port logiciel. L’adresse peut être gardée vide pour autoriser tout client du réseau à accéder au serveur mais on peut la préciser si on souhaite par exemple réserver la connexion à un client spécifique (et disposant d’une adresse fixe). Le port est codé par un entier entre 0 et 65535, mais pour ce genre d’application les valeurs utilisables se situent entre 1024 et 49151. Il est préférable d’éviter les valeurs listées parmi les ports enregistrés à l’IANA.
Une fois que le connecteur a démarré son écoute, il va attendre une connexion par un client jusqu’à ce que le délai imposé soit atteint. Si aucun client ne se présente et qu’aucun délai d’attente n’a été fixé en amont, cette attente se prolongera jusqu’à ce que le serveur soit arrêté (par exemple en interrompant la fenêtre d’exécution). Le délai peut également être fixé par défaut pour toutes les nouvelles interfaces de connexion avec la fonction socket.setdefaulttimeout
.
Dans le cas où un client se connecte au serveur, la méthode accept
fournit un objet représentant la connexion et l’adresse IP du client. Avec cette connexion, on peut par exemple envoyer un message au client ou en récupérer de sa part (avec le même délai fixé par le connecteur ou par modification de la connexion avec la même méthode). Ces messages doivent être formulés ou encodés au format binaire et décodés de même pour apparaitre comme une chaine de caractères.
L’argument obligatoire de la méthode recv
est un entier qui décrit la taille du tampon, c’est-à-dire la taille maximale du message à récupérer en une seule fois. On choisit en général une petite puissance de 2. Il n’est pas pertinent de prévoir beaucoup plus grand, car la transmission de l’intégralité du message n’est pas garantie du premier coup, même s’il est de taille inférieure à celle du tampon. On verra plus bas comment reconstituer un message envoyé en plusieurs fois.
Script du client
Sur le même ordinateur que le serveur, on peut installer aussi un script de client.
import socket # chargement de la bibliothèque
# création de la connexion au serveur
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as connexion:
connexion.connect(("localhost", 5999)) # connexion au serveur
message = connexion.recv(1024) # récupération du message d’accueil du serveur
# affichage des paramètres du serveur puis du message
print("Message du serveur", connexion.getpeername())
print(message.decode())
reponse = input("> ") # saisie de la réponse au clavier
connexion.send(bytes(reponse, "utf-8")) # envoi de la réponse au serveur
print(connexion.recv(1024).decode()) # affichage du retour
Remarquons tout d’abord que cette connexion est créée exactement comme le connecteur du serveur. La différence se situe dans l’instruction suivante, avec la méthode connect
au lieu de bind
.
Puisque le client et le serveur sont lancés sur le même ordinateur, on peut chercher la connexion avec l’adresse localhost
sans avoir besoin de connaitre l’adresse IP. Mais les affichages des deux scripts vont nous permettre d’obtenir cette adresse et de vérifier qu’elle est pour l’instant la même côté serveur et côté client. Il s’agit normalement de l’adresse locale 127.0.0.1
correspondant à localhost
.
En revanche, le port utilisé par le serveur n’est pas le même que celui utilisé par le client. Ce dernier change d’ailleurs à chaque connexion.
On constate aussi que les scripts des deux parties sont parfaitement coordonnés : le client attend que le serveur lui adresse un message en premier, puis c’est au tour du client d’envoyer un message au serveur qui lui envoie enfin un retour. En l’absence d’un tel protocole, certains messages pourraient être envoyés sans être lus, ou au contraire l’une des deux parties pourrait attendre longtemps un message qui ne vient pas. On verra plus bas comment concevoir des protocoles plus souples qui assurent l’efficacité de la communication sans contraindre autant sa durée et l’alternance des envois.
Sur un réseau local
Pour créer un client sur un autre ordinateur que le serveur, on a besoin d’identifier le serveur sur le réseau. Au sein d’un réseau domestique relié par un boitier passerelle (box internet), les adresses IP peuvent être récupérées sur l’interface d’administration du boitier (normalement, à l’adresse 192.168.1.1
). On peut aussi trouver le nom d’hôte en deux lignes de commande avec Python depuis l’ordinateur qui fera office de serveur :
import socket
socket.gethostname()
'monnomdemachine'
Ce nom de machine peut ensuite remplacer la mention localhost
dans le script du client.
Pour que le serveur soit accessible depuis un réseau plus large (potentiellement par n’importe quel internaute), il faut ouvrir ce serveur en modifiant les paramètres de son boitier internet ou disposer d’un serveur dédié ou partagé hébergé par un fournisseur. La discussion de ces aspects sort du cadre du présent article, mais si vous savez le faire, il suffira de préciser l’adresse du serveur dans le script client pour obtenir le même niveau d’efficacité.
Problème de la transmission partielle
Lorsqu’on a été habitué à ce qu’une instruction déclenche une erreur si elle n’a pas été complètement effectuée, le traitement des communications par connexion entre serveur et client peut être un peu déroutant. Par exemple, si on envoie un message d’une dizaine d’octets et que le destinataire a prévu sa réception avec une borne de 1024 octets, on pourrait s’attendre à ce que le message soit transmis dans son intégralité. Or ce n’est pas forcément le cas, et sans pour autant déclencher une erreur ni d’un côté ni de l’autre.
Du côté de l’émetteur, la valeur de retour de la fonction send
indique quand même le nombre de bytes effectivement transmis. Il est donc possible de savoir si le message est complètement arrivé ou non. Cela est même mis à profit dans la méthode sendall
qui réalise des envois successifs jusqu’à ce que le nombre de bytes transmis égale le nombre de bytes du message initial.
Du côté du recepteur en revanche, on sait combien de bytes ont été reçus, mais pas combien auraient dû être transmis. Or l’attente d’éventuels nouveaux paquets peut bloquer l’action du programme. Il existe plusieurs solutions assez différentes à ce problème.
Contrôles de fin de transmission
- Messages de taille calibrée
- Il suffit de récupérer des paquets jusqu’à ce que cette taille soit atteinte. Cette solution est efficace lorsque les actions sont restreintes à une courte liste avec un codage simple : morpion, échecs, Puissance 4…
- Terminateur et délimiteurs
- Si un byte particulier n’est pas utilisé au sein du message, sa lecture peut signaler la fin du message. Des délimiteurs employés uniquement par paires (un ouvrant et un fermant, comme des parenthèses) peuvent aussi encadrer le message sans ambiguïté. Ces solutions sont pratiques pour des actions exprimées dans un langage courant.
- Préfixe indicateur de longueur
- En utilisant quelques bytes en nombre fixé au début du message pour spécifier sa taille, on s’assure que le récepteur lise le préfixe en entier puis continue sa réception jusqu’à ce que la taille du message soit atteinte. Avec un préfixe de deux octets (donc deux bytes a priori), cette taille peut aller jusqu’à plus de 64 ko, ce qui est déjà confortable. Cette solution est préférable lorsque le message peut contenir des bytes de valeur arbitraire en grand nombre (image et son, données numériques…)
- La méthode
tour
, sans argument, renvoie un couple(actifs, informations)
consistant en une liste de joueurs actifs et une chaine binaire contenant les informations à leur transmettre, sauf si cette chaine est vide. Dans ce cas, le serveur doit récupérer les actions de chacun des joueurs actifs. Si aucun joueur n’est actif, les informations sont récupérées par le serveur. En particulier, si aucun joueur n’est actif avec des informations vides, le jeu s’arrête. - La méthode
realisation
reçoit une table associative décrivant pour chaque identifiant de joueur auditionné les actions choisies.
Ces différentes procédures de contrôle peuvent être codées avec les fonctions suivantes.
def receptioncalibree(connexion, calibre, tailletampon):
"""Cette fonction récupère via la /connexion/
un message binaire de longueur fixe /calibre/
par paquets de taille maximale /tailletampon/."""
a = bytearray() # accumulateur binaire initialement vide
while len(a) < calibre: # boucle pour remplir l’accumulateur
t = min(tailletampon, calibre-len(a)) # taille attendue
r = connexion.recv(t) # réception du paquet suivant
a.extend(r) # accumulation
return a
def receptionterminante(connexion, terminateur, tailletampon):
a = bytearray()
while True: # boucle infinie, sortie sur test
r = connexion.recv(tailletampon) # réception d’un paquet
if r.endswith(terminateur): # fin du message
a.extend(r[:-1]) # accumulation sans le terminateur
break # sortie de boucle
# sinon, le message n’est pas terminé
a.extend(r) # accumulation du paquet
return a
def receptiondelimitee(connexion, ouvrant, fermant, tailletampon):
a = bytearray()
profondeur = 0 # nombre de délimiteurs ouverts et pas encore fermés
while True:
r = connexion.recv(tailletampon)
a.extend(r)
profondeur += r.count(ouvrant)-r.count(fermant)
if profondeur == 0:
break # fin du message
return a[1:-1]
def receptionprefixee(connexion, tailleprefixe, tailletampon):
a = bytearray()
while len(a) < tailleprefixe: # boucle de lecture du préfixe
r = connexion.recv(tailletampon)
a.extend(r)
# calcul de la taille du message avec des bytes de poids décroissant
for i in range(tailleprefixe):
taille = taille*256 + a[i]
a = a[tailleprefixe:] # élimination du préfixe
while len(a) < taille: # boucle de lecture du message
r = connexion.recv(min(tailletampon, taille-len(a)))
a.extend(r)
return a
Gestion des erreurs de format
Ces quatre fonctions permettent bien de récupérer les messages envoyés lorsqu’ils respectent le format attendu, mais en matière de communication entre ordinateurs, il faut toujours s’attendre au pire. D’abord, on définit une erreur spécifique pour signaler un message non conforme. Ensuite on met en place une fonction pour récupérer un paquet à la fois et vérifier qu’il est non vide dans une limite de temps. Enfin, on joint à chaque fonction de réception une fonction d’émission qui contrôle le format des messages émis.
import time
class ErreurFormat(Exception):
"""Message reçu non conforme au format prévu par le serveur."""
pass # pas d’action particulière
def reception(connexion, tailletampon, ultimatum):
# calcul du délai d’attente
if ultimatum is not None:
connexion.settimeout(ultimatum-time.time())
r = connexion.recv(tailletampon)
if not r: # connexion coupée
raise ConnectionAbortedError
return r
def emissioncalibree(connexion, message, calibre):
if len(message) != calibre: # taille différente de celle attendue
raise ErreurFormat # erreur levée et fin de l’exécution de la fonction
connexion.sendall(message) # sinon, envoi du message
def receptioncalibree(connexion, calibre, tailletampon, ultimatum):
a = bytearray()
while len(a) < calibre:
t = min(tailletampon, calibre-len(a))
r = reception(connexion, t, ultimatum)
a.extend(r)
return a
def emissionterminante(connexion, message, terminateur=3):
# pas de terminateur dans le message qu’il est censé terminer !
if terminateur in message:
raise ErreurFormat
connexion.sendall(message+bytes([terminateur])
def receptionterminante(connexion, terminateur=3, tailletampon, ultimatum):
a = bytearray()
while True:
r = reception(connexion, tailletampon, ultimatum)
i = r.find(terminateur) # recherche du terminateur dans le paquet
if i > -1: # terminateur trouvé
if i < len(r)-1: # avant la fin
raise ErreurFormat
else: # terminateur positionné à la fin
a.extend(r[:-1]) # accumulation paquet sans le terminateur
break # sortie de boucle
a.extend(r)
return a
def emissiondelimitee(connexion, message, delimiteurs=(2,3)):
ouvrant, fermant = delimiteurs
profondeur = 0
for c in message: # parcours des éléments du message
if c == ouvrant: # ouverture d’un délimiteur
profondeur += 1 # augmentation de la profondeur
elif c == fermant: # fermeture d’un délimiteur
profondeur -= 1 # diminution de la profondeur
if profondeur < 0: # un délimiteur fermant en trop
raise ErreurFormat
if profondeur: # au moins un délimiteur fermant qui manque
raise ErreurFormat
# envoi du message avec les délimiteurs à chaque extrémité
connexion.sendall(bytes([ouvrant]) + message + bytes([fermant]))
def receptiondelimitee(connexion, delimiteurs=(2,3), tailletampon, ultimatum):
ouvrant, fermant = delimiteurs
r = reception(connexion, tailletampon, ultimatum)
if r[0] != ouvrant: # délimiteur ouvrant manquant au début
raise ErreurFormat
a = bytearray()
profondeur = 0
while True:
for i in range(len(r)):
if r[i] == ouvrant:
profondeur += 1
elif r[i] == fermant:
profondeur -= 1
if profondeur == 0:
if i+1 == len(r):
break # fin du message, sortie de boucle
# sinon dernier délimiteur fermé avant la fin
raise ErreurFormat
a.extend(r)
r = reception(connexion, tailletampon, ultimatum)
return a[1:-1]
def emissionprefixee(connexion, message, tailleprefixe):
prefixe = bytearray()
taille = len(message)
for i in range(tailleprefixe): # calcul du préfixe en base 256
prefixe.append(taille % 256)
taille = taille // 256
if taille: # taille restante signe que len(message)≥256^tailleprefixe
raise OverflowError
# concaténation avec préfixe réordonné par poids décroissants
connexion.sendall(prefixe[::-1] + message)
def receptionprefixee(connexion, tailleprefixe, tailletampon, ultimatum):
a = bytearray()
while len(a) < tailleprefixe:
r = reception(connexion, tailletampon, ultimatum)
a.extend(r)
# reconstitution taille du message
for i in range(tailleprefixe):
taille = taille*256 + a[i]
a = a[tailleprefixe:]
while len(a) < taille:
t = min(tailletampon, taille-len(a))
r = reception(connexion, t, ultimatum)
a.extend(r)
if len(a) > taille:
raise ErreurFormat
return a
Protocole
Les jeux présentent une très grande diversité de modes de fonctionnement. De façon très générale, chaque joueur prend des décisions qui affectent l’état de jeu (placement de pions, pose de cartes, communications orales ou écrites…) et il reçoit des informations de la part des autres joueurs et de divers éléments de jeu (pioche de cartes, dés…). L’aspect qui nous intéresse ici est l’ordre de jeu, c’est-à-dire la détermination des joueurs qui peuvent intervenir dans un état de jeu donné.
Dans un jeu séquentiel comme les échecs, le tarot ou le Yahtzee, à chaque moment du jeu un seul joueur a la main, c’est-à-dire qu’il peut accomplir une ou plusieurs actions avant de passer la main au joueur suivant, sans que l’état de jeu n’évolue entre deux actions. Le jeu peut donc s’organiser en récupérant successivement les actions des joueurs, y compris une éventuelle décision de passer au joueur suivant. Même dans le cas d’un jeu simultané comme pierre-feuille-ciseaux, les choix des joueurs peuvent être récupérés séquentiellement avant d’être communiqués globalement.
Au contraire, les jeux en temps réel évoluent entre les actions de joueurs, et dans certains cas autorisent simultanément plusieurs joueurs à réaliser une action (même si une seule action est traitée à la fois). On trouve ainsi beaucoup de jeux à deviner et les jeux de rapidité physique.
Temps discret
On considère une instance de jeu comme un objet Python service
muni de deux méthodes.
import socket
import time
class ErreurFormat(Exception):
"""Message reçu non conforme au format prévu par le serveur."""
pass # pas d’action particulière
class Client: # création d’une classe d’objets
def __init__(self, connexion, adresse, delai=600, tailletampon=1024):
self.connexion = connexion
self.adresse = adresse
class Serveur:
def __init__(self, service, delai=60, tailletampon=1024):
self.service = service
self.delai = delai
self.tailletampon = tailletampon
self.emission = self._emission
self.reception = self._reception
self.ultimatum = None
def _emission(self, connexion, message):
connexion.sendall(message)
def _reception(self, connexion, taille = None):
# calcul du délai d’attente
if self.ultimatum is not None:
connexion.settimeout(self.ultimatum-time.time())
r = connexion.recv(self.tailletampon if taille is None \
else min(self.tailletampon, taille))
if not r: # connexion coupée
raise ConnectionAbortedError
return r
def _emissioncalibree(self, connexion, message):
if len(message) != self.calibre:
raise ErreurFormat
connexion.sendall(message)
def _receptioncalibree(self, connexion):
a = bytearray()
while len(a) < self.calibre:
r = self._reception(connexion, self.calibre-len(a))
a.extend(r)
return a
def _emissionterminante(self, connexion, message):
if self.terminateur in message:
raise ErreurFormat
connexion.sendall(message+bytes([self.terminateur])
def _receptionterminante(self, connexion):
a = bytearray()
while True:
r = self._reception(connexion)
i = r.find(self.terminateur)
if i > -1:
if i < len(r)-1:
raise ErreurFormat
a.extend(r[:-1])
break
a.extend(r)
return a
def _emissiondelimitee(self, connexion, message):
profondeur = 0
for c in message:
if c == self.ouvrant:
profondeur += 1
elif c == self.fermant:
profondeur -= 1
if profondeur < 0:
raise ErreurFormat
if profondeur:
raise ErreurFormat
connexion.sendall(bytes([self.ouvrant])+message+bytes([self.fermant]))
def _receptiondelimitee(self, connexion):
r = self._reception(connexion)
if r[0] != self.ouvrant:
raise ErreurFormat
a = bytearray()
profondeur = 0
while True:
for i in range(len(r)):
if r[i] == self.ouvrant:
profondeur += 1
elif r[i] == self.fermant:
profondeur -= 1
if profondeur == 0:
if i+1 == len(r):
break
raise ErreurFormat
a.extend(r)
r = self._reception(connexion)
return a[1:-1]
def _emissionprefixee(self, connexion, message):
prefixe = bytearray()
taille = len(message)
for i in range(self.calibre):
prefixe.append(taille % 256)
taille = taille // 256
if taille:
raise OverflowError
connexion.sendall(prefixe[::-1] + message)
def _receptionprefixee(self, connexion):
a = bytearray()
while len(a) < self.calibre:
r = self._reception(connexion)
a.extend(r)
for i in range(self.calibre):
taille = taille*256 + a[i]
a = a[tailleprefixe:]
while len(a) < taille:
r = self._reception(connexion, taille-len(a))
a.extend(r)
if len(a) > taille:
raise ErreurFormat
return a
def demarre(self, attente, port=5999)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as connecteur:
connecteur.bind(("", port))
connecteur.listen()
clients = []
try:
for i in range(nbclients):
if self.delai:
connecteur.settimeout(self.delai)
connexion, adresse = connecteur.accept()
print("Connexion de", adresse[0], "sur le port", adresse[1])
clients.append(Client(connexion, adresse))
except TimeoutError:
pass
while True:
actifs, informations = self.service.tour()
if actifs:
if informations:
for i in actifs:
if self.delai:
self.ultimatum = time.time()+self.delai
self.emission(clients[i].connexion, informations)
else:
resultats = {}
for i in actifs:
try:
if self.delai:
self.ultimatum = time.time()+self.delai
resultats[i] = self.reception(clients[i].connexion)
except:
pass
self.service.realisation(resultats)
elif informations:
self.emission(clients[i].connexion, informations)
else:
break
for i in actifs:
clients[i].connexion.close()
Temps continu
Pour autoriser simultanément plusieurs joueurs à intervenir dans le jeu, mais aussi pour mieux gérer les éventuelles déconnexions et reconnexions pendant la partie, il vaut mieux mettre en place une communication par fils. Ce sera l’objet d’un prochain article.