Je veux créer un simple jeu multijoueur client-serveur en temps réel comme projet pour ma classe de réseautage.
J'ai beaucoup lu sur les modèles de réseaux multijoueurs en temps réel et je comprends les relations entre le client et le serveur et les techniques de compensation de décalage.
Ce que je veux faire, c'est quelque chose de similaire au modèle de réseau Quake 3: fondamentalement, le serveur stocke un instantané de tout l'état du jeu; lors de la réception des entrées des clients, le serveur crée un nouvel instantané reflétant les changements. Ensuite, il calcule les différences entre le nouvel instantané et le dernier et les envoie aux clients, afin qu'ils puissent être synchronisés.
Cette approche me semble vraiment solide - si le client et le serveur ont une connexion stable, seule la quantité minimale nécessaire de données sera envoyée pour les synchroniser. Si le client n'est plus synchronisé, un instantané complet peut également être demandé.
Je ne peux cependant pas trouver un bon moyen de mettre en œuvre le système d'instantanés. Je trouve vraiment difficile de m'éloigner de l'architecture de programmation solo et de réfléchir à la façon dont je pourrais stocker l'état du jeu de telle manière que:
- Toutes les données sont séparées de la logique
- Les différences peuvent être calculées entre un instantané des états du jeu
- Les entités de jeu peuvent toujours être facilement manipulées via le code
Comment une classe d' instantanés est -elle implémentée? Comment les entités et leurs données sont-elles stockées? Chaque entité cliente a-t-elle un ID qui correspond à un ID sur le serveur?
Comment les différences d'instantanés sont-elles calculées?
En général: comment un système d'instantanés d'état du jeu serait-il implémenté?
la source
Réponses:
Vous pouvez calculer le delta de l'instantané (modification de son état synchronisé précédent) en conservant deux instances d'instantanés: une actuelle et la dernière synchronisée.
Lorsque l'entrée client arrive, vous modifiez l'instantané actuel. Ensuite, quand il est temps d'envoyer du delta aux clients, vous calculez le dernier instantané synchronisé avec un champ actuel par champ (récursivement) et calculez et sérialisez le delta. Pour la sérialisation, vous pouvez attribuer un ID unique à chaque champ de l'étendue de sa classe (par opposition à l'étendue de l'état global). Le client et le serveur doivent partager la même structure de données pour l'état global afin que le client comprenne à quoi un ID particulier est appliqué.
Ensuite, lorsque le delta est calculé, vous clonez l'état actuel et en faites le dernier état synchronisé, vous avez donc maintenant un état actuel et un état synchronisé identiques, mais des instances différentes afin de pouvoir modifier l'état actuel et ne pas affecter l'autre.
Cette approche peut être plus facile à mettre en œuvre, en particulier avec l'aide de la réflexion (si vous avez un tel luxe), mais peut être lente, même si vous opitimisez fortement la partie réflexion (en construisant votre schéma de données pour mettre en cache la plupart des appels de réflexion). Principalement parce que vous devez comparer deux copies d'un état potentiellement volumineux. Bien sûr, cela dépend de la façon dont vous implémentez la comparaison et de votre langue. Il peut être rapide en C ++ avec un comparateur codé en dur mais pas si flexible: tout changement de votre structure d'état globale nécessite une modification de ce comparateur, et ces changements sont si fréquents lors des premières étapes du projet.
Une autre approche consiste à utiliser des drapeaux sales. Chaque fois qu'une entrée client arrive, vous l'appliquez à votre copie unique de l'état global et marquez les champs correspondants comme sales. Ensuite, quand il est temps de synchroniser les clients, vous sérialisez (récursivement) les champs sales en utilisant les mêmes ID uniques. L'inconvénient (mineur) est que, parfois, vous envoyez plus de données que strictement requis: par exemple,
int field1
était initialement 0, puis affecté 1 (et marqué sale) et ensuite attribué à nouveau 0 (mais reste sale). L'avantage est qu'avec une énorme structure de données hiérarchique, vous n'avez pas besoin de l'analyser complètement pour calculer le delta, uniquement des chemins sales.En général, cette tâche peut être assez compliquée, dépend de la flexibilité de la solution finale. Par exemple, Unity3D 5 (à venir) utilisera des attributs pour spécifier les données qui doivent être auto-synchronisées avec les clients (approche très flexible, vous n'avez rien d'autre à faire que d'ajouter un attribut à vos champs), puis générer du code en tant que étape post-construction. Plus de détails ici.
la source
Vous devez d'abord savoir comment représenter vos données pertinentes d'une manière conforme au protocole. Cela dépend des données pertinentes pour le jeu. Je vais utiliser un jeu RTS comme exemple.
À des fins de réseautage, toutes les entités du jeu sont énumérées (par exemple, les micros, les unités, les bâtiments, les ressources naturelles, les destructibles).
Les joueurs doivent avoir les données qui les concernent (par exemple toutes les unités visibles):
Au début, le joueur doit recevoir l'état complet avant de pouvoir entrer dans le jeu (ou alternativement toutes les informations pertinentes pour ce joueur).
Chaque unité a un ID entier. Les attributs sont énumérés et ont donc également des identificateurs intégraux. Les identifiants des unités ne doivent pas nécessairement être de 32 bits (ce pourrait être le cas si nous ne sommes pas frugaux). Il pourrait très bien être de 20 bits (laissant 10 bits pour les attributs). L'ID des unités doit être unique, il pourrait très bien être attribué par un compteur lorsque l'unité est instanciée et / ou ajoutée au monde du jeu (les bâtiments et les ressources sont considérés comme une unité immobile et les ressources peuvent se voir attribuer un ID lorsque la carte est chargé).
Le serveur stocke l'état global actuel. L'état le plus récent mis à jour de chaque joueur est représenté par un pointeur sur l'un
list
des changements récents (tous les changements après que le pointeur n'a pas encore été envoyé à ce joueur). Les modifications sont ajoutées aulist
moment où elles se produisent. Une fois que le serveur a terminé d'envoyer la dernière mise à jour, il peut commencer à parcourir la liste: le serveur déplace le pointeur du joueur le long de la liste jusqu'à sa queue, collecte toutes les modifications en cours de route et les place dans un tampon qui sera envoyé à le joueur (c'est-à-dire que le format du protocole peut être quelque chose comme ceci: unit_id; attr_id; new_value) Les nouvelles unités sont également considérées comme des changements et sont envoyées avec toutes leurs valeurs d'attribut aux joueurs récepteurs.Si vous n'utilisez pas une langue avec un garbage collector, vous devrez configurer un pointeur paresseux qui sera en retard, puis rattraper le pointeur de lecteur le plus obsolète de la liste, libérant des objets en cours de route. Vous pouvez vous rappeler quel joueur est le plus obsolète dans un tas prioritaire ou simplement itérer et libérer jusqu'à ce que le pointeur paresseux soit égal (c'est-à-dire qu'il pointe vers le même élément que l'un des pointeurs des joueurs).
Certaines questions que vous n'avez pas soulevées et qui me semblent intéressantes sont les suivantes:
la source