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 connecteurconnecteur_ouvert.settimeout(600) # délai d’attente par défaut en secondesconnecteur_ouvert.listen() # démarrage du connecteur# récupération de la connexion d’un client avec son adresse IPconnexion, adresse = connecteur_ouvert.accept()# présentation du clientprint("Connexion de", adresse[0], "sur le port", adresse[1])with connexion:connexion.send(b"Comment vous appelez-vous ?") # envoi d’un message au clientrecu = connexion.recv(1024) # récupération de la réponse du clientnom = recu.decode() # décodage de la réponse# encodage du retour au clientconnexion.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 serveurwith socket.socket(socket.AF_INET, socket.SOCK_STREAM) as connexion:connexion.connect(("localhost", 5999)) # connexion au serveurmessage = connexion.recv(1024) # récupération du message d’accueil du serveur# affichage des paramètres du serveur puis du messageprint("Message du serveur", connexion.getpeername())print(message.decode())reponse = input("> ") # saisie de la réponse au clavierconnexion.send(bytes(reponse, "utf-8")) # envoi de la réponse au serveurprint(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 socketsocket.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
realisationreç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 videwhile len(a) < calibre: # boucle pour remplir l’accumulateurt = min(tailletampon, calibre-len(a)) # taille attenduer = connexion.recv(t) # réception du paquet suivanta.extend(r) # accumulationreturn adef receptionterminante(connexion, terminateur, tailletampon):a = bytearray()while True: # boucle infinie, sortie sur testr = connexion.recv(tailletampon) # réception d’un paquetif r.endswith(terminateur): # fin du messagea.extend(r[:-1]) # accumulation sans le terminateurbreak # sortie de boucle# sinon, le message n’est pas terminéa.extend(r) # accumulation du paquetreturn adef receptiondelimitee(connexion, ouvrant, fermant, tailletampon):a = bytearray()profondeur = 0 # nombre de délimiteurs ouverts et pas encore ferméswhile True:r = connexion.recv(tailletampon)a.extend(r)profondeur += r.count(ouvrant)-r.count(fermant)if profondeur == 0:break # fin du messagereturn a[1:-1]def receptionprefixee(connexion, tailleprefixe, tailletampon):a = bytearray()while len(a) < tailleprefixe: # boucle de lecture du préfixer = connexion.recv(tailletampon)a.extend(r)# calcul de la taille du message avec des bytes de poids décroissantfor i in range(tailleprefixe):taille = taille*256 + a[i]a = a[tailleprefixe:] # élimination du préfixewhile len(a) < taille: # boucle de lecture du messager = 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 timeclass ErreurFormat(Exception):"""Message reçu non conforme au format prévu par le serveur."""pass # pas d’action particulièredef reception(connexion, tailletampon, ultimatum):# calcul du délai d’attenteif ultimatum is not None:connexion.settimeout(ultimatum-time.time())r = connexion.recv(tailletampon)if not r: # connexion coupéeraise ConnectionAbortedErrorreturn rdef emissioncalibree(connexion, message, calibre):if len(message) != calibre: # taille différente de celle attendueraise ErreurFormat # erreur levée et fin de l’exécution de la fonctionconnexion.sendall(message) # sinon, envoi du messagedef 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 adef emissionterminante(connexion, message, terminateur=3):# pas de terminateur dans le message qu’il est censé terminer !if terminateur in message:raise ErreurFormatconnexion.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 paquetif i > -1: # terminateur trouvéif i < len(r)-1: # avant la finraise ErreurFormatelse: # terminateur positionné à la fina.extend(r[:-1]) # accumulation paquet sans le terminateurbreak # sortie de bouclea.extend(r)return adef emissiondelimitee(connexion, message, delimiteurs=(2,3)):ouvrant, fermant = delimiteursprofondeur = 0for c in message: # parcours des éléments du messageif c == ouvrant: # ouverture d’un délimiteurprofondeur += 1 # augmentation de la profondeurelif c == fermant: # fermeture d’un délimiteurprofondeur -= 1 # diminution de la profondeurif profondeur < 0: # un délimiteur fermant en tropraise ErreurFormatif profondeur: # au moins un délimiteur fermant qui manqueraise 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 = delimiteursr = reception(connexion, tailletampon, ultimatum)if r[0] != ouvrant: # délimiteur ouvrant manquant au débutraise ErreurFormata = bytearray()profondeur = 0while True:for i in range(len(r)):if r[i] == ouvrant:profondeur += 1elif r[i] == fermant:profondeur -= 1if profondeur == 0:if i+1 == len(r):break # fin du message, sortie de boucle# sinon dernier délimiteur fermé avant la finraise ErreurFormata.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 256prefixe.append(taille % 256)taille = taille // 256if taille: # taille restante signe que len(message)≥256^tailleprefixeraise OverflowError# concaténation avec préfixe réordonné par poids décroissantsconnexion.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 messagefor 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 ErreurFormatreturn 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 socketimport timeclass ErreurFormat(Exception):"""Message reçu non conforme au format prévu par le serveur."""pass # pas d’action particulièreclass Client: # création d’une classe d’objetsdef __init__(self, connexion, adresse, delai=600, tailletampon=1024):self.connexion = connexionself.adresse = adresseclass Serveur:def __init__(self, service, delai=60, tailletampon=1024):self.service = serviceself.delai = delaiself.tailletampon = tailletamponself.emission = self._emissionself.reception = self._receptionself.ultimatum = Nonedef _emission(self, connexion, message):connexion.sendall(message)def _reception(self, connexion, taille = None):# calcul du délai d’attenteif 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éeraise ConnectionAbortedErrorreturn rdef _emissioncalibree(self, connexion, message):if len(message) != self.calibre:raise ErreurFormatconnexion.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 adef _emissionterminante(self, connexion, message):if self.terminateur in message:raise ErreurFormatconnexion.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 ErreurFormata.extend(r[:-1])breaka.extend(r)return adef _emissiondelimitee(self, connexion, message):profondeur = 0for c in message:if c == self.ouvrant:profondeur += 1elif c == self.fermant:profondeur -= 1if profondeur < 0:raise ErreurFormatif profondeur:raise ErreurFormatconnexion.sendall(bytes([self.ouvrant])+message+bytes([self.fermant]))def _receptiondelimitee(self, connexion):r = self._reception(connexion)if r[0] != self.ouvrant:raise ErreurFormata = bytearray()profondeur = 0while True:for i in range(len(r)):if r[i] == self.ouvrant:profondeur += 1elif r[i] == self.fermant:profondeur -= 1if profondeur == 0:if i+1 == len(r):breakraise ErreurFormata.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 // 256if taille:raise OverflowErrorconnexion.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 ErreurFormatreturn adef 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:passwhile True:actifs, informations = self.service.tour()if actifs:if informations:for i in actifs:if self.delai:self.ultimatum = time.time()+self.delaiself.emission(clients[i].connexion, informations)else:resultats = {}for i in actifs:try:if self.delai:self.ultimatum = time.time()+self.delairesultats[i] = self.reception(clients[i].connexion)except:passself.service.realisation(resultats)elif informations:self.emission(clients[i].connexion, informations)else:breakfor 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.
